Compare commits
73 Commits
315f464dfc
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 137769f3f1 | |||
| ef28eabaf8 | |||
| 008d2ba9c8 | |||
| 8e3a0892d4 | |||
| 023e428b9c | |||
| 527a38daf1 | |||
| 30bcd8eeb6 | |||
| 884215c403 | |||
| 8a89806e44 | |||
| 26720b98f0 | |||
| 4633a1b8a2 | |||
| 87986a12d2 | |||
| 0858af1029 | |||
| acddce8e2f | |||
| 77c5d9482a | |||
| adef16ccdd | |||
| c19825620c | |||
| 190431740a | |||
| 25cf6d32b6 | |||
| 1a04bb3835 | |||
| 1cf3b0d78f | |||
| 66dced0823 | |||
| b1b5163cc3 | |||
| bb9bde799a | |||
| dc7a70811a | |||
| ff212885c4 | |||
| 90c2d7e409 | |||
| 60e6e2697f | |||
| 20511773fc | |||
| 31fb6a9bd1 | |||
| 103e10639a | |||
| 97c5a0390a | |||
| 8b547765f0 | |||
| 5215cf5c4f | |||
| e66c37161c | |||
| e4270c9b14 | |||
| e69ad0f32d | |||
| 0a2246bdbe | |||
| e5a6df7af7 | |||
| 25cb3c47e7 | |||
| 020d72084d | |||
| 358b28c421 | |||
| b0550af6a3 | |||
| 43c994a32a | |||
| 1e47ee21cd | |||
| fed591183b | |||
| 1f95cf905a | |||
| 15f5b591b9 | |||
| 184f29c7af | |||
| 813d6a38c9 | |||
| 23ae700194 | |||
| 49218a836b | |||
| 2fcf892324 | |||
| 8645cab79a | |||
| 70c95bf660 | |||
| d9f08ff719 | |||
| 2c91e41d10 | |||
| e3860036f8 | |||
| b3fc6a5950 | |||
| a858a066e4 | |||
| 10edd95d4d | |||
| 08e03f6a38 | |||
| a20ba0af27 | |||
| 6458dbd3aa | |||
| 43e55dfa85 | |||
| 430985c629 | |||
| 4635ef08d5 | |||
| 49c0c6d908 | |||
| a8beb3e620 | |||
| 934c1022e0 | |||
| 7248a8bd95 | |||
| b2db3233b2 | |||
| edb6d05ec1 |
21
README.md
21
README.md
@@ -26,6 +26,13 @@ the [configuration contract](docs/config.md#effective-configuration-diffs) and
|
|||||||
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
||||||
its safe changed-region records and maintained workflows.
|
its safe changed-region records and maintained workflows.
|
||||||
|
|
||||||
|
Sources can also be explicitly placed as sequence items, and definitions can
|
||||||
|
register selected sequences to append records with distinct string IDs during
|
||||||
|
strict composition. Higher-priority sequences still replace lower-priority
|
||||||
|
sequences as whole values. See the [sequence contract](docs/config.md#strict-document-composition),
|
||||||
|
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
|
||||||
|
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
@@ -62,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
|
imports form one strictly composed tier. Explicit underlay groups sit below it
|
||||||
and above typed defaults; a selected overlay and explicit CLI overrides sit
|
and above typed defaults; a selected overlay and explicit CLI overrides sit
|
||||||
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
||||||
owns the detailed combination rules and input-specific constraints.
|
owns the detailed combination rules and input-specific constraints. At
|
||||||
|
definition-registered paths only, strict composition can append records with
|
||||||
|
distinct explicit IDs; ordinary and unregistered sequences keep their conflict
|
||||||
|
or whole-value replacement behavior.
|
||||||
|
|
||||||
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
||||||
these priorities and how genuine new needs can justify extending the shared
|
these priorities and how genuine new needs can justify extending the shared
|
||||||
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
|
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
|
||||||
|
consideration in future releases.
|
||||||
|
|
||||||
The [configuration contract](docs/config.md) describes implemented behavior,
|
The [configuration contract](docs/config.md) describes implemented behavior,
|
||||||
including [input destinations](docs/config.md#input-destinations). The
|
including [input destinations](docs/config.md#input-destinations). The
|
||||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
||||||
layered, and mounted-collection examples alongside the migration shapes. The
|
layered, mounted-collection, and identified-prompt examples alongside the
|
||||||
accepted placement decision is recorded in [ADR 0011](docs/adr/0011-input-destinations.md).
|
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
|
See the [development guide](docs/development.md) for repository orientation and
|
||||||
the [release procedure](docs/release.md) for maintainer-only candidate and
|
the [release procedure](docs/release.md) for maintainer-only candidate and
|
||||||
publication steps.
|
publication steps.
|
||||||
|
|||||||
@@ -43,10 +43,31 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
||||||
}
|
}
|
||||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
||||||
|
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
||||||
|
}
|
||||||
|
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &SourceVersionCheck{
|
||||||
|
Path: fromEnginePath(value.Path),
|
||||||
|
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||||
|
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &engine.SourceVersionCheck{
|
||||||
|
Path: toEnginePath(value.Path),
|
||||||
|
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||||
|
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func fromEngineOrigin(value engine.Origin) Origin {
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted. The [input-destinations roadmap](../roadmap/input-destinations.md)
|
Accepted and implemented. Current placement behavior is owned by the
|
||||||
owns implementation status and the [implementation plan](../roadmap/implementation.md)
|
[configuration contract](../config.md#input-destinations). The accepted
|
||||||
owns sequencing and completion evidence.
|
relative-import extension is recorded in
|
||||||
|
[ADR 0012](0012-relative-import-destinations.md). The completed roadmap, consumer
|
||||||
|
request, and review records are preserved at `e4270c9` under `docs/roadmap/` in
|
||||||
|
Git history; the original input-destination implementation plan remains at
|
||||||
|
`315f464:docs/roadmap/implementation.md`.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
|
|||||||
78
docs/adr/0012-relative-import-destinations.md
Normal file
78
docs/adr/0012-relative-import-destinations.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR 0012: Place Imported Configuration at Relative Destinations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted and implemented. The
|
||||||
|
[configuration contract](../config.md#input-destinations) owns current behavior.
|
||||||
|
The completed roadmap and review records are preserved at `e4270c9` under
|
||||||
|
`docs/roadmap/` in Git history; the relative-import implementation plan remains
|
||||||
|
at `d9f08ff:docs/roadmap/implementation.md`.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-14
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ConfigKit can place an input document at a literal mapping path while retaining
|
||||||
|
source identity, composition, and inspection in its normal loading lifecycle.
|
||||||
|
An importing document can currently bring in other documents, but those
|
||||||
|
contributions inherit only the importing input's destination. Consumers that
|
||||||
|
assemble a collection therefore cannot declare the layout of imported
|
||||||
|
fragments alongside the import itself without rewriting source documents or
|
||||||
|
performing another merge outside ConfigKit.
|
||||||
|
|
||||||
|
The extension should reuse input placement and the existing import lifecycle.
|
||||||
|
Logical application placement must remain independent from physical path
|
||||||
|
resolution, source identity, precedence, and resource ownership.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add an optional `destination` field to each YAML import declaration. Its value
|
||||||
|
is a sequence of strings representing literal mapping-key segments. Omission
|
||||||
|
and an empty sequence add no segments. A non-empty destination appends to the
|
||||||
|
effective destination of the immediately importing document; nested imports
|
||||||
|
continue from the resulting child destination. Every match of one glob
|
||||||
|
declaration receives the same computed destination.
|
||||||
|
|
||||||
|
Use the existing input-destination validation and reserved-key rules. Do not
|
||||||
|
interpret destination segments as filesystem paths, separators, sequence
|
||||||
|
indices, or root-reset instructions. Physical import paths continue to resolve
|
||||||
|
from their declaring source under existing containment rules.
|
||||||
|
|
||||||
|
Imports remain in their existing strict-composition group. A destination does
|
||||||
|
not grant precedence, bypass cycle or repeated-source rules, or change source
|
||||||
|
identity. Existing placement, version ownership, source records, diagnostics,
|
||||||
|
resource contexts, inheritance, history, and inspection mechanisms continue to
|
||||||
|
govern the placed contributions. No public Go entry point or independent
|
||||||
|
specification-header behavior is introduced.
|
||||||
|
|
||||||
|
This is additive YAML functionality and is minor-release work. Version
|
||||||
|
selection, release notes, roadmap retirement, and publication remain separate
|
||||||
|
release-preparation work.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Require complete application-root destinations on every import.** This
|
||||||
|
avoids inherited context but repeats collection prefixes in nested source
|
||||||
|
declarations and does not express placement relative to the importing
|
||||||
|
document.
|
||||||
|
- **Encode destinations as dotted or slash-delimited strings.** This introduces
|
||||||
|
separator and escaping rules even though existing paths already represent
|
||||||
|
literal mapping keys as segments.
|
||||||
|
- **Rewrite or merge imported documents in consumers.** This duplicates source
|
||||||
|
placement, strict composition, attribution, and inspection behavior outside
|
||||||
|
ConfigKit's loading lifecycle.
|
||||||
|
- **Add independent specification headers or collection-specific validation.**
|
||||||
|
These are separate design questions and are not required to declare relative
|
||||||
|
placement.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Import declarations can describe the logical placement of their contributions
|
||||||
|
while preserving the physical source and existing composition lifecycle.
|
||||||
|
Placement is immediate-parent-relative, so nested imports accumulate their
|
||||||
|
declared segments and sibling declarations remain independent. The feature is
|
||||||
|
implemented behind the existing public loading surface; detailed validation,
|
||||||
|
diagnostic, accounting, and inspection requirements are tracked by the
|
||||||
|
implementation roadmap.
|
||||||
92
docs/adr/0013-shared-source-version-checks.md
Normal file
92
docs/adr/0013-shared-source-version-checks.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# ADR 0013: Check Source Versions with One Shared Policy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted and implemented. The
|
||||||
|
[configuration contract](../config.md#shared-source-version-checks) owns current
|
||||||
|
behavior. The completed roadmap, implementation plan, consumer request, and
|
||||||
|
resolved review findings are preserved at `e4270c9` under `docs/roadmap/` in
|
||||||
|
Git history; the [development guide](../development.md#baseline-validation)
|
||||||
|
provides retrieval examples.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-15
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications that assemble one configuration from independently authored
|
||||||
|
documents need a way to require that each contributing document declares a
|
||||||
|
version accepted by the application's existing `Definition.Version` checker.
|
||||||
|
Checking only the eventual composed value lets another source hide a rejected
|
||||||
|
declaration, while separate consumer-side parsing duplicates ConfigKit's source
|
||||||
|
handling and diagnostics.
|
||||||
|
|
||||||
|
The extension must preserve one application-owned acceptance policy and the
|
||||||
|
existing loading lifecycle. Source declarations are compatibility evidence, not
|
||||||
|
independent fragment schemas or values to compare for equality. Existing
|
||||||
|
composition, placement, precedence, source identity, diagnostics, and
|
||||||
|
sensitivity rules remain authoritative.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Provide a load-wide source-version mode for ordinary and layered loading. The
|
||||||
|
zero-value mode preserves current behavior. The enabled modes check declarations
|
||||||
|
when present or require them, using the existing `Definition.Version` path and
|
||||||
|
checker. Interpret the configured path relative to each participating source
|
||||||
|
before placement or composition. Different declarations may be accepted by the
|
||||||
|
same checker; ConfigKit adds no equality, SemVer, or expected-version rule.
|
||||||
|
|
||||||
|
Apply the selected policy to the actual main document, direct underlays,
|
||||||
|
imports, and selected overlays. Missing non-main declarations are skipped in
|
||||||
|
if-present mode and rejected in required mode. Existing main absence behavior
|
||||||
|
remains, with required mode additionally rejecting an absent declaration on an
|
||||||
|
actual main document. `NoSource`, unmatched optional inputs, synthetic field
|
||||||
|
operations, and empty groups do not become documents that need declarations.
|
||||||
|
|
||||||
|
Reuse the existing version vocabulary and validation boundary: absence, null,
|
||||||
|
exact strings, and strict decimal integers retain their established meanings;
|
||||||
|
unsupported present values fail version-type validation. Warnings remain
|
||||||
|
nonfatal, and source declarations do not replace final application decoding or
|
||||||
|
domain validation.
|
||||||
|
|
||||||
|
After an accepted non-main declaration is checked, consume exactly that node
|
||||||
|
before its remaining application content is composed or overlaid. Preserve the
|
||||||
|
main declaration and its value semantics. Keep the declaration, source-local
|
||||||
|
path, physical location, and checked absence as detached source evidence,
|
||||||
|
separate from effective application values and history. Apply established
|
||||||
|
sensitivity rules to that evidence, including original mounted paths and
|
||||||
|
ancestors; retain existing safe failure disclosure.
|
||||||
|
|
||||||
|
In enabled modes, the main declaration remains source-local while its protected
|
||||||
|
application path is derived from the main input's destination. This distinction
|
||||||
|
does not change default-mode path interpretation or weaken protection of
|
||||||
|
remaining payload and synthetic operations.
|
||||||
|
|
||||||
|
Public option-field additions and the enabled-mode interpretation for mounted
|
||||||
|
main inputs are compatibility considerations for separately authorized minor-
|
||||||
|
release preparation. This decision does not select a release version or
|
||||||
|
authorize release notes or publication.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Check only the final composed value.** Later sources can hide an earlier
|
||||||
|
rejected declaration, so this does not validate every contributing document.
|
||||||
|
- **Let consumers parse and check each source.** This duplicates acquisition,
|
||||||
|
source attribution, safe diagnostics, and lifecycle coordination in every
|
||||||
|
application.
|
||||||
|
- **Require declarations to be equal or impose a universal version grammar.**
|
||||||
|
Consumers already own the acceptance policy; ConfigKit need not invent an
|
||||||
|
expected value or a domain-specific compatibility rule.
|
||||||
|
- **Keep accepted non-main declarations in application payload.** A selected
|
||||||
|
source-local declaration is metadata for this policy and cannot also remain
|
||||||
|
an ordinary field at the same path without ambiguity.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can apply one existing version policy across contributing documents
|
||||||
|
and select gradual or required adoption per load. Accepted non-main headers no
|
||||||
|
longer participate in application composition; their inspectable evidence is
|
||||||
|
kept separate and subject to sensitivity classification. Default callers retain
|
||||||
|
current behavior. The implementation plan records the concrete API names,
|
||||||
|
integration order, and validation evidence.
|
||||||
77
docs/adr/0014-sequence-placement-and-composition.md
Normal file
77
docs/adr/0014-sequence-placement-and-composition.md
Normal 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.
|
||||||
212
docs/config.md
212
docs/config.md
@@ -67,13 +67,18 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
|
|||||||
contributes no source, and continues through defaults, decode, and projection. A
|
contributes no source, and continues through defaults, decode, and projection. A
|
||||||
missing file remains an error; it is never interpreted as no input.
|
missing file remains an error; it is never interpreted as no input.
|
||||||
|
|
||||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||||
semantic validator, explicit path and glob imports, one selected overlay, and
|
`PlacementMode`, explicit path and glob imports, one selected overlay, filesystem
|
||||||
filesystem context. It also accepts explicit CLI override records, static secret
|
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
may register strict sequence composition paths through
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
|
||||||
containing a reserved `configkit` mapping key is rejected while imports are
|
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
||||||
disabled.
|
non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||||
|
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||||
|
CLI coordinates remain rooted at the application root. `NoSource` rejects
|
||||||
|
imports, active overlays, and filesystem context as invalid combinations but
|
||||||
|
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
|
||||||
|
while imports are disabled.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||||
@@ -149,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
|
Byte, source, node, and glob-entry budgets are shared across the load, while
|
||||||
the existing per-source and import-depth bounds still apply.
|
the existing per-source and import-depth bounds still apply.
|
||||||
|
|
||||||
|
When a source mapping is explicitly placed as an item, its effective destination
|
||||||
|
is the sequence container. After assembly assigns an index, nested source
|
||||||
|
destinations are projected through that item. Physical source locations and
|
||||||
|
resource contexts continue to identify the declaring source.
|
||||||
|
|
||||||
#### Input destinations
|
#### Input destinations
|
||||||
|
|
||||||
`Input.Destination` places the source's application mapping beneath a path
|
`Input.Destination` places the source's application mapping beneath a path
|
||||||
@@ -160,25 +170,44 @@ indexes. Explicit index segments and paths that introduce the reserved
|
|||||||
`configkit` or mapping-valued `extends` structure are invalid input options and
|
`configkit` or mapping-valued `extends` structure are invalid input options and
|
||||||
produce a structured `LoadError` before source acquisition.
|
produce a structured `LoadError` before source acquisition.
|
||||||
|
|
||||||
Source-root `configkit.imports` directives are processed before placement, and
|
Source-root `configkit.imports` directives are processed before placement. An
|
||||||
each imported application contribution inherits its input's destination,
|
import without an explicit destination inherits the effective destination of
|
||||||
including transitive imports. A valid destination on `NoSource()` contributes
|
its importing document, including through nested imports. An import may instead
|
||||||
nothing; an actual empty mapping materializes its destination containers. Once
|
declare a relative destination as a sequence of literal mapping keys; its keys
|
||||||
placed, contributions follow the same strict composition within a tier and
|
append to the importing document's effective destination. Omission and `[]`
|
||||||
recursive precedence behavior described above. A selected overlay and CLI
|
add no keys. Nested imports continue relative to their immediate importing
|
||||||
overrides remain rooted at the application root and are not moved beneath the
|
document, each declaration is independent of its siblings, and all matches of
|
||||||
main input's destination.
|
one glob receive the same resulting destination. This logical placement does
|
||||||
|
not change how the physical import path resolves from its declaring file.
|
||||||
|
|
||||||
`SourceInfo.Destination` reports the placement for each acquired input source,
|
A valid destination on `NoSource()` contributes nothing; an actual empty
|
||||||
including imports. Placement does not change source IDs, acquisition identity,
|
mapping materializes its destination containers. Once placed, contributions
|
||||||
physical source locations, or passive resource contexts. Introduced ancestor
|
follow the same strict composition within a tier and recursive precedence
|
||||||
mappings use the contributing document's root location as a source fallback;
|
behavior described above. A selected overlay and CLI overrides remain rooted at
|
||||||
authored content retains its original source coordinates. Returned source
|
the application root and are not moved beneath the main input's destination.
|
||||||
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)
|
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||||
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||||
and explicit resource access without a second configuration pass.
|
and explicit resource access without a second configuration pass.
|
||||||
|
The accepted rationale for input placement and its relative-import extension
|
||||||
|
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
|
||||||
|
[ADR 0012](adr/0012-relative-import-destinations.md).
|
||||||
|
|
||||||
### Sibling inheritance
|
### Sibling inheritance
|
||||||
|
|
||||||
@@ -296,6 +325,67 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
|
|||||||
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
||||||
successful result. Callback panics remain programming failures.
|
successful result. Callback panics remain programming failures.
|
||||||
|
|
||||||
|
#### Shared source-version checks
|
||||||
|
|
||||||
|
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
|
||||||
|
one `Definition.Version` policy applies across source documents. The zero value
|
||||||
|
`SourceVersionModeDefault` preserves the existing main-only check at its
|
||||||
|
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
|
||||||
|
checks each participating document that declares a version and permits absent
|
||||||
|
non-main declarations. `SourceVersionModeRequired` rejects a participating
|
||||||
|
document without a declaration. Enabled modes require a usable `VersionRule`
|
||||||
|
and checker; an unknown mode or unusable rule is an options error before source
|
||||||
|
access or callbacks.
|
||||||
|
|
||||||
|
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
|
||||||
|
document's own coordinates, before placement and composition. The main document,
|
||||||
|
direct underlay documents, imported documents (including transitive and glob
|
||||||
|
matches), and the selected overlay participate. `NoSource`, absent optional
|
||||||
|
imports, empty underlay groups, defaults, resolution output, inheritance, and
|
||||||
|
synthetic CLI overrides are not documents to check. An absent declaration in an
|
||||||
|
actual main document still reaches the checker in if-present mode; required mode
|
||||||
|
rejects it first. With `NoSource`, the existing single absent-main checker
|
||||||
|
behavior remains in both enabled modes. If-present omissions in non-main
|
||||||
|
documents skip the checker. Explicit null is present and reaches the checker;
|
||||||
|
unsupported selected values produce `version.type`. No equality with the main
|
||||||
|
declaration or non-null rule is imposed. Acceptance establishes only that the
|
||||||
|
declaration is accepted by the consumer's checker; it does not establish that
|
||||||
|
each fragment conforms to a complete application schema. Final assembled
|
||||||
|
decoding and application validation still run once.
|
||||||
|
|
||||||
|
A header that is absent below a structural control follows the same omission
|
||||||
|
policy. Actual header/control overlap is rejected, and ordinary structural
|
||||||
|
validation and main-version ownership protection continue independently.
|
||||||
|
|
||||||
|
Accepted non-main declarations are consumed from their owned payload before
|
||||||
|
composition; their authored ancestors remain, even if empty. The main
|
||||||
|
declaration remains in the application. Ordinary release metadata should use a
|
||||||
|
different path. Enabled main protection uses the main input's effective path:
|
||||||
|
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
|
||||||
|
the remaining payload against imports, underlays, inheritance, and CLI
|
||||||
|
overrides. Thus a mounted main looks up a local header while ownership checks
|
||||||
|
protect its destination-prefixed application path. Main checker diagnostics and
|
||||||
|
declaration locations use source-local coordinates; ordinary payload
|
||||||
|
diagnostics continue to use effective paths. Default-mode diagnostics are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
|
||||||
|
occur. Otherwise it records a source-local `Path`, the observed `Version` state
|
||||||
|
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
|
||||||
|
available `Location`, and whether text was redacted. A non-nil record with
|
||||||
|
`Present == false` records observed absence; it does not say the checker ran or
|
||||||
|
accepted a declaration. Absent evidence still identifies the full requested
|
||||||
|
header path, while its location identifies the nearest available source node.
|
||||||
|
During callbacks and in failed-load source records,
|
||||||
|
text is conservatively withheld because final classification may not be
|
||||||
|
available. Successful final source records reflect completed sensitivity
|
||||||
|
classification at the declaration's mounted path and ancestors, including
|
||||||
|
historical classification. Redaction empties `Text` while retaining presence
|
||||||
|
and null state. Returned copies are detached; evidence does not reopen inputs.
|
||||||
|
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
|
||||||
|
decision and the [development guide](development.md#baseline-validation) for
|
||||||
|
the completed implementation and remediation records in Git history.
|
||||||
|
|
||||||
### Layered metadata
|
### Layered metadata
|
||||||
|
|
||||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||||
@@ -326,6 +416,11 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
|
|||||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||||
value for absent input remains non-explicit.
|
value for absent input remains non-explicit.
|
||||||
|
|
||||||
|
Appending registered records during strict composition assigns each contribution
|
||||||
|
its assembled sequence index in history. A later precedence tier that supplies a
|
||||||
|
sequence records replacement at the sequence path; history does not imply
|
||||||
|
identity-based merging across tiers.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||||
has an overlay-layer composite origin with the lower contributing locations followed
|
has an overlay-layer composite origin with the lower contributing locations followed
|
||||||
@@ -673,12 +768,47 @@ Application defaults do not participate in this operation.
|
|||||||
| Mapping | Mapping | Recurse by mapping key |
|
| Mapping | Mapping | Recurse by mapping key |
|
||||||
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
||||||
|
|
||||||
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
|
Every non-mapping overlap is a conflict, even when values are equal, except for
|
||||||
sequences are present atomic values, so they also conflict when repeated; sequences
|
sequence/sequence contributions at a registered sequence-composition path. Nulls
|
||||||
are never joined by index. Disjoint entries under shared mappings compose, making
|
remain atomic conflicts. Unregistered sequences conflict when repeated and are
|
||||||
|
never joined by index. Disjoint entries under shared mappings compose, making
|
||||||
stable keyed mappings the natural representation for independently contributed
|
stable keyed mappings the natural representation for independently contributed
|
||||||
entries.
|
entries.
|
||||||
|
|
||||||
|
`Definition.SequenceCompositions` registers literal mapping paths and one literal
|
||||||
|
`IdentityField` for sequences that should compose independently authored records.
|
||||||
|
Registration paths follow input-destination key rules: parsed numeric tokens are
|
||||||
|
literal mapping keys, explicit index segments and reserved `configkit` or
|
||||||
|
mapping-valued `extends` segments are invalid, and the root registration is
|
||||||
|
harmless and unused. The identity field is one literal string key; neither the
|
||||||
|
field name nor identity strings are normalized.
|
||||||
|
At each registered path, every actual sequence contribution in a strict tier is
|
||||||
|
validated, including a singleton contribution: each record must be a mapping with
|
||||||
|
a present string identity field. Distinct exact identity strings append in source
|
||||||
|
and list order; duplicate identities conflict even if the records are otherwise
|
||||||
|
equal or disjoint. Empty identity strings are valid. Malformed records produce
|
||||||
|
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
|
||||||
|
sequence container and source locations identifying a duplicate without exposing
|
||||||
|
identity values. Missing paths and non-sequence values do not fail by themselves.
|
||||||
|
Identical registrations coalesce; conflicting registrations at one path and
|
||||||
|
invalid registration paths produce `options.invalid`. Unused and root registrations
|
||||||
|
are allowed and have no effect.
|
||||||
|
|
||||||
|
Registration changes strict composition only. Each underlay group and the main
|
||||||
|
tier has independent duplicate-identity state. Precedence assembly is unchanged:
|
||||||
|
a present higher-priority sequence replaces the lower sequence as one value,
|
||||||
|
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
|
||||||
|
and application resolution do not run the registered identity check. The accepted
|
||||||
|
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
|
||||||
|
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
||||||
|
shows item path and glob imports, helper-supplied identity, and main-tier
|
||||||
|
replacement of a lower-priority sequence.
|
||||||
|
When an enabled source-version mode is selected, declarations are checked in each
|
||||||
|
source's own coordinates before placement and composition. Accepted non-main
|
||||||
|
headers are consumed before strict composition and cannot supply a registered
|
||||||
|
record identity. Final typed decoding and application validation still run on the
|
||||||
|
completed configuration.
|
||||||
|
|
||||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||||
Shared mapping containers retain all contributing value locations as their composite
|
Shared mapping containers retain all contributing value locations as their composite
|
||||||
@@ -778,9 +908,13 @@ zero.
|
|||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
string `path` or `glob`, an optional boolean `optional`, and an optional
|
||||||
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
`destination` sequence of strings. Every destination string is one literal
|
||||||
imports disabled, any occurrence remains `import.disabled`.
|
mapping key and follows the input-destination validation rules above; quote
|
||||||
|
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
|
||||||
|
and non-string members are `import.invalid`, as are all other unsupported shapes
|
||||||
|
and fields. A `configkit` key below the root is invalid. With imports disabled,
|
||||||
|
any occurrence remains `import.disabled`.
|
||||||
|
|
||||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||||
@@ -809,6 +943,30 @@ walk failures, matched directories, invalid documents, escapes, and repeated tar
|
|||||||
remain errors. Final candidates are bounded by the remaining source limit before
|
remain errors. Final candidates are bounded by the remaining source limit before
|
||||||
their contents are accumulated.
|
their contents are accumulated.
|
||||||
|
|
||||||
|
Each path or glob declaration may include `placement: mapping` or
|
||||||
|
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
||||||
|
including every match of a glob; placement is not inherited. Sequence-item
|
||||||
|
placement requires a nonempty effective destination and wraps the imported
|
||||||
|
mapping as one element of a sequence there. It does not split source sequences
|
||||||
|
or infer identity fields. Repeated sequences still conflict unless their path is
|
||||||
|
registered through `Definition.SequenceCompositions`; registration does not
|
||||||
|
change placement or precedence behavior. Sequence records retain source
|
||||||
|
locations through placement and history; indexes describe their assembled
|
||||||
|
positions while source coordinates continue to identify the declaring files.
|
||||||
|
Header/version policy and final application validation retain their existing
|
||||||
|
owners and timing; sequence registration is a strict-composition rule only.
|
||||||
|
|
||||||
|
An item import first opens a local mapping scope. Its body and ordinary helper
|
||||||
|
imports compose within that record. An omitted helper destination or
|
||||||
|
`destination: []` places the helper at the record root; other destinations are
|
||||||
|
relative to the immediate importing source. A helper may supply fields such as
|
||||||
|
the record's `id`. An explicit nested `sequence-item` import creates a sequence
|
||||||
|
inside the record, and its placement is independent of the enclosing item.
|
||||||
|
At the record root, an item import must name a nonempty nested destination.
|
||||||
|
Physical paths continue to resolve from the file that declares each import.
|
||||||
|
An actual empty mapping contributes an empty item, while an optional unmatched
|
||||||
|
path or glob contributes nothing.
|
||||||
|
|
||||||
For host files, the configuration root defaults to the selected file's canonical
|
For host files, the configuration root defaults to the selected file's canonical
|
||||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||||
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
|
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ sanitized shapes using only ConfigKit and the Go standard library:
|
|||||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||||
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
||||||
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
|
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
|
||||||
| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
|
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
|
||||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||||
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||||
|
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
||||||
|
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
|
||||||
|
|
||||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||||
executable examples and ordinary tests. It contains no private application data,
|
executable examples and ordinary tests. It contains no private application data,
|
||||||
@@ -53,6 +55,18 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
|||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
execution profile concepts.
|
||||||
|
|
||||||
|
## Shared source-version checks
|
||||||
|
|
||||||
|
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||||
|
if-present and required modes with one `Definition.Version` rule across a mounted
|
||||||
|
main input and an underlay. The example reads only sanitized
|
||||||
|
`SourceInfo.VersionCheck` fields; its executable test is in
|
||||||
|
[`layered_test.go`](../../examples/consumers/layered_test.go). The
|
||||||
|
[configuration contract](../config.md#shared-source-version-checks) defines
|
||||||
|
participation, source-local lookup and diagnostics, non-main header consumption,
|
||||||
|
main-path protection, default behavior, and evidence disclosure. Accepted
|
||||||
|
declarations do not replace final assembled decoding or application validation.
|
||||||
|
|
||||||
## Effective configuration diffs
|
## Effective configuration diffs
|
||||||
|
|
||||||
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
|
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
|
||||||
@@ -109,12 +123,21 @@ and may fail independently of the completed configuration load. The
|
|||||||
[configuration contract](../config.md#source-resource-contexts) owns exact
|
[configuration contract](../config.md#source-resource-contexts) owns exact
|
||||||
lifetime and precedence rules.
|
lifetime and precedence rules.
|
||||||
|
|
||||||
|
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
|
||||||
|
counterpart: its entry opts into imports through `Load`, imports metadata at the
|
||||||
|
root, and places a prompt beneath `prompts` using YAML. The executable example
|
||||||
|
and behavior test are in
|
||||||
|
[`layered_test.go`](../../examples/consumers/layered_test.go).
|
||||||
|
|
||||||
## Input destinations and mounted collections
|
## Input destinations and mounted collections
|
||||||
|
|
||||||
Main and underlay inputs use the same destination field; imported application
|
Main and underlay inputs use the same destination field. An import without a
|
||||||
contributions inherit the declaring input's destination. A destination is an
|
`destination` inherits its declaring document's effective destination; an
|
||||||
application mapping path, independent of filesystem context. The configuration
|
explicit sequence of key strings appends a relative suffix. Nested imports
|
||||||
contract owns the complete behavior and validation rules.
|
append to their immediate parent's effective destination, while filesystem
|
||||||
|
paths remain relative to the declaring file. Destinations are application
|
||||||
|
mapping paths, independent of filesystem context. The configuration contract
|
||||||
|
owns the complete behavior and validation rules.
|
||||||
|
|
||||||
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
|
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
|
||||||
mounts one mapping source beneath literal collection/prompt keys and loads a
|
mounts one mapping source beneath literal collection/prompt keys and loads a
|
||||||
@@ -123,12 +146,13 @@ default. Consumers pass paths, not filenames or namespace strings for ConfigKit
|
|||||||
to interpret.
|
to interpret.
|
||||||
|
|
||||||
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
|
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
|
||||||
embedded collection manifest with a source-root import, separate prompt and
|
embedded collection manifest mounted beneath a literal collection path. The
|
||||||
profile peers, and a higher-priority input that changes only one prompt's
|
entry's YAML places nested metadata imports at the collection root and prompt
|
||||||
ordinary `version` field. The main document alone supplies the application
|
and profile peers beneath their own keys. A higher-priority input changes only
|
||||||
schema version. A prompt's local `extends` relationship resolves after all
|
one prompt's ordinary `version` field. The main document alone supplies the
|
||||||
tiers, so `custom` inherits the final `brief` content and fields while retaining
|
application schema version. A prompt's local `extends` relationship resolves
|
||||||
its own profile selection.
|
after all tiers, so `custom` inherits the final `brief` content and fields while
|
||||||
|
retaining its own profile selection.
|
||||||
|
|
||||||
The resolver follows each effective `content_file` origin to its declaring
|
The resolver follows each effective `content_file` origin to its declaring
|
||||||
source context. This also works for `custom`'s inherited resource. The
|
source context. This also works for `custom`'s inherited resource. The
|
||||||
@@ -140,6 +164,24 @@ relative resources, and strict peer conflicts. The
|
|||||||
[configuration contract](../config.md#input-destinations) owns shared placement,
|
[configuration contract](../config.md#input-destinations) owns shared placement,
|
||||||
import, source-location, and ownership behavior.
|
import, source-location, and ownership behavior.
|
||||||
|
|
||||||
|
## Identified prompt sequences
|
||||||
|
|
||||||
|
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
||||||
|
definition registration for the literal `prompts` path and `id` field. Its main
|
||||||
|
file imports one prompt by path and others by glob, placing each source as an
|
||||||
|
item. A nested ordinary helper import supplies an item's ID. The main tier
|
||||||
|
strictly appends the distinct records in contribution order; its completed
|
||||||
|
sequence replaces the lower-priority default collection as one value.
|
||||||
|
|
||||||
|
The example's `validatePromptCatalog` remains application-owned. It checks that
|
||||||
|
prompt names and message lists satisfy this consumer's requirements after
|
||||||
|
ConfigKit has composed, projected, and decoded the configuration. It does not
|
||||||
|
add recursive record merging, identity-based overlay, or final uniqueness checks
|
||||||
|
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
|
||||||
|
[import syntax](../config.md#explicit-imports), and
|
||||||
|
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
|
||||||
|
boundaries.
|
||||||
|
|
||||||
Known module catalogs should use canonical object entries with a module identifier
|
Known module catalogs should use canonical object entries with a module identifier
|
||||||
and typed options. A small union struct is sufficient when the available option
|
and typed options. A small union struct is sufficient when the available option
|
||||||
fields are known; validation selects which fields apply to each module. Runtime-
|
fields are known; validation selects which fields apply to each module. Runtime-
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ public behavior.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
||||||
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
|
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
|
||||||
|
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
||||||
| Documentation changes | The owning document |
|
| Documentation changes | The owning document |
|
||||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
|
||||||
| Code and test changes | The owning contract and tests from the map below |
|
| Code and test changes | The owning contract and tests from the map below |
|
||||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||||
| Release preparation | [Release procedure](release.md) |
|
| Release preparation | [Release procedure](release.md) |
|
||||||
@@ -50,6 +51,7 @@ public behavior.
|
|||||||
Accepted decisions:
|
Accepted decisions:
|
||||||
|
|
||||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||||
|
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
|
||||||
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
||||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||||
@@ -106,7 +108,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Remaining optimization candidates belong to
|
Remaining optimization candidates belong to
|
||||||
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
|
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
||||||
|
|
||||||
The completed effective-configuration diff roadmap, implementation plan, and
|
The completed effective-configuration diff roadmap, implementation plan, and
|
||||||
resolved review findings are preserved in Git history at `1c2610c` under
|
resolved review findings are preserved in Git history at `1c2610c` under
|
||||||
@@ -115,6 +117,39 @@ resolved review findings are preserved in Git history at `1c2610c` under
|
|||||||
comparison and disclosure behavior, and
|
comparison and disclosure behavior, and
|
||||||
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
|
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
|
||||||
|
|
||||||
|
The completed input-destination, relative-import-destination, and shared
|
||||||
|
source-version roadmaps, associated consumer request, implementation plan, and
|
||||||
|
resolved review findings are preserved in Git history at `e4270c9` under
|
||||||
|
`docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show e4270c9:docs/roadmap/implementation.md
|
||||||
|
git show e4270c9:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Current behavior belongs to the configuration contract's
|
||||||
|
[input destinations](config.md#input-destinations),
|
||||||
|
[imports](config.md#explicit-imports), and
|
||||||
|
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||||
|
Explicit sequence-item placement and registered strict composition are owned by
|
||||||
|
the [strict composition](config.md#strict-document-composition) and
|
||||||
|
[imports](config.md#explicit-imports) sections. The
|
||||||
|
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
||||||
|
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
|
||||||
|
records the decision.
|
||||||
|
|
||||||
|
The completed sequence-composition roadmap, implementation plan, and resolved
|
||||||
|
remediation record are preserved in Git history at `008d2ba` under
|
||||||
|
`docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 008d2ba:docs/roadmap/sequence-composition.md
|
||||||
|
git show 008d2ba:docs/roadmap/implementation.md
|
||||||
|
git show 008d2ba:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||||
|
|
||||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
Acquire declared dependencies explicitly before entering an offline
|
Acquire declared dependencies explicitly before entering an offline
|
||||||
development or validation environment:
|
development or validation environment:
|
||||||
@@ -161,19 +196,20 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Task | Behavioral test owners | Focused command |
|
| Task | Behavioral test owners | Focused command |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||||
| Input destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier and import tests](../internal/engine/destination_tiers_test.go), [integration and provenance tests](../internal/engine/destination_integration_test.go), [public facade tests](../facade_destination_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Mounted'`, `go test . -run 'Destination|FacadeMounted'`, and `go test ./examples/consumers -run 'CollectionBundle|CollectionPeer'` |
|
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
||||||
|
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||||
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||||
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||||
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ Implemented component owners:
|
|||||||
|
|
||||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
grouped strict composition, recursive precedence assembly, per-input mapping
|
||||||
placement within that assembly, and destination-local sibling inheritance
|
and sequence-item placement, definition-registered distinct-ID sequence
|
||||||
before typed decoding. It retains source placement alongside physical source
|
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
|
identity and owns schema
|
||||||
inspection, lifecycle coordination, callback auditing,
|
inspection, lifecycle coordination, callback auditing,
|
||||||
provenance/history, passive source-context retention, secret resolution and
|
provenance/history, passive source-context retention, secret resolution and
|
||||||
@@ -43,8 +48,13 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
|||||||
internal types.
|
internal types.
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||||
semantics, including [input destinations](../config.md#input-destinations) and
|
semantics, including [input destinations](../config.md#input-destinations),
|
||||||
|
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||||
|
The [strict composition contract](../config.md#strict-document-composition)
|
||||||
|
owns sequence placement, registration, replacement, and history behavior; the
|
||||||
|
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
|
||||||
|
maintained example.
|
||||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||||
The [architecture policy](../policy/architecture.md) owns contributor
|
The [architecture policy](../policy/architecture.md) owns contributor
|
||||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||||
@@ -53,4 +63,8 @@ decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
|
|||||||
decision. See the [development guide](../development.md) for validation commands
|
decision. See the [development guide](../development.md) for validation commands
|
||||||
and [release procedure](../release.md) for source-candidate gates. [ADR
|
and [release procedure](../release.md) for source-candidate gates. [ADR
|
||||||
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
|
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
|
||||||
post-assembly structural operation.
|
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
|
||||||
|
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||||
|
records the source-version decision.
|
||||||
|
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||||
|
sequence placement and composition.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-09-29
|
**Last Updated**: 2026-10-16
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ to ADRs for decision rationale.
|
|||||||
ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
||||||
and resolution across consuming applications. The [component overview](../internal/overview.md)
|
and resolution across consuming applications. The [component overview](../internal/overview.md)
|
||||||
owns implementation inventory, the [configuration contract](../config.md) owns
|
owns implementation inventory, the [configuration contract](../config.md) owns
|
||||||
implemented behavior, and the [deferred scope](../roadmap/deferred.md) preserves
|
implemented behavior, and the [future feature list](../roadmap/future.md) preserves
|
||||||
deferred scope.
|
candidates for future consideration.
|
||||||
|
|
||||||
## Adopted Constraints And Implemented Structure
|
## Adopted Constraints And Implemented Structure
|
||||||
|
|
||||||
@@ -100,11 +100,13 @@ version policy. Applications also own main-file search conventions, CLI wiring,
|
|||||||
remote acquisition, runtime initialization, and resource checks.
|
remote acquisition, runtime initialization, and resource checks.
|
||||||
|
|
||||||
The engine applies each input's mapping destination within the existing source
|
The engine applies each input's mapping destination within the existing source
|
||||||
assembly and retains that placement with source identity and inspection records;
|
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||||
|
retains each effective placement with source identity and inspection records;
|
||||||
the root facade owns the public fields and conversions. The
|
the root facade owns the public fields and conversions. The
|
||||||
[configuration contract](../config.md#input-destinations) defines the observable
|
[configuration contract](../config.md#input-destinations) defines the observable
|
||||||
placement behavior, and [ADR 0011](../adr/0011-input-destinations.md) records the
|
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
|
||||||
accepted decision.
|
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
||||||
|
the import extension.
|
||||||
|
|
||||||
Library code must not depend on consuming applications or initialize their
|
Library code must not depend on consuming applications or initialize their
|
||||||
runtime resources. It must not perform implicit network access, discover
|
runtime resources. It must not perform implicit network access, discover
|
||||||
@@ -161,13 +163,23 @@ framework on this library.
|
|||||||
|
|
||||||
Within a configuration tier, independent contributions use strict composition:
|
Within a configuration tier, independent contributions use strict composition:
|
||||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
shared mappings combine recursively, while overlapping non-mapping definitions
|
||||||
are conflicts, including equal values. Between precedence tiers, intentional
|
are conflicts, including equal values, except that sequences at explicitly
|
||||||
overlay combines mappings recursively, retains omitted lower-priority children,
|
registered paths append records with distinct present string identities. Between
|
||||||
|
precedence tiers, intentional overlay combines mappings recursively, retains
|
||||||
|
omitted lower-priority children,
|
||||||
and replaces other present values according to the existing overlay rules.
|
and replaces other present values according to the existing overlay rules.
|
||||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
Tier precedence is explicit; traversal order within a tier establishes stable
|
||||||
reporting, not permission to override. The distinction and its rationale are
|
reporting, not permission to override. The distinction and its rationale are
|
||||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||||
|
|
||||||
|
Sequence-item placement and sequence registration are explicit source and
|
||||||
|
definition controls; registration does not change precedence replacement. The
|
||||||
|
[configuration contract](../config.md#strict-document-composition) defines the
|
||||||
|
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
|
||||||
|
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||||
|
shows the implemented subset. This feature does not authorize arbitrary
|
||||||
|
configurable merge strategies.
|
||||||
|
|
||||||
The main document and its imports form a strictly composed tier. Explicit
|
The main document and its imports form a strictly composed tier. Explicit
|
||||||
underlay groups compose strictly within each group, then combine recursively
|
underlay groups compose strictly within each group, then combine recursively
|
||||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||||
@@ -241,7 +253,7 @@ for safe messages supplied by their hooks.
|
|||||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||||
merge strategies, additional configuration languages, or application runtime
|
merge strategies, additional configuration languages, or application runtime
|
||||||
management without concrete consumer evidence and an explicit design decision.
|
management without concrete consumer evidence and an explicit design decision.
|
||||||
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
Record significant decisions to exclude capabilities, and their rationale, in
|
||||||
owns the deferred-feature inventory. Significant boundary
|
ADRs. Significant boundary changes require an ADR and corresponding
|
||||||
changes require an ADR and corresponding policy/contract updates; implementation
|
policy/contract updates; implementation convenience alone is not a reason to
|
||||||
convenience alone is not a reason to broaden the public API.
|
broaden the public API.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.11
|
**Base Policy Version:** 1.11
|
||||||
|
|
||||||
**Last Updated**: 2026-09-28
|
**Last Updated**: 2026-10-16
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||||
|
|
||||||
@@ -38,9 +38,10 @@ roadmap owns implementation status and sequencing until the decision is
|
|||||||
implemented. Current architecture, user, operator, integration, and internal
|
implemented. Current architecture, user, operator, integration, and internal
|
||||||
documentation are updated when the behavior lands.
|
documentation are updated when the behavior lands.
|
||||||
|
|
||||||
`docs/roadmap/deferred.md` owns deliberate exclusions; task-specific plans
|
`docs/roadmap/future.md` owns potential enhancements and their open questions;
|
||||||
own execution order and progress. Completed plans retire during release
|
task-specific plans own execution order and progress. Record significant
|
||||||
preparation.
|
architectural exclusions and their rationale in ADRs. Do not maintain a roadmap
|
||||||
|
inventory of rejected proposals. Completed plans retire during release preparation.
|
||||||
|
|
||||||
### Audience And Detail
|
### Audience And Detail
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ secret values.
|
|||||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||||
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and conditions for reconsideration. | Task execution and current behavior. |
|
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
||||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
||||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||||
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
|
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
|
||||||
@@ -156,8 +157,10 @@ Use sequentially numbered ADR filenames such as
|
|||||||
|
|
||||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||||
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
Rejected architectural alternatives and significant architectural exclusions
|
||||||
belong in the roadmap. Routine choices need no ADR.
|
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||||
|
ideas. Routine choices and rejected suggestions need no ADR unless they
|
||||||
|
represent a significant architectural decision.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
|||||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||||
validation toolchain. Retire completed planning and measurement records into
|
validation toolchain. Retire completed planning and measurement records into
|
||||||
Git history while preserving [deferred work](../roadmap/deferred.md).
|
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
||||||
|
|
||||||
## Compatibility and upgrade
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
|||||||
70
docs/releases/v0.8.0.md
Normal file
70
docs/releases/v0.8.0.md
Normal 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)
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Deferred Scope
|
|
||||||
|
|
||||||
This document owns intentionally excluded features, not a committed delivery plan.
|
|
||||||
The [architecture policy](../policy/architecture.md) governs changes to these
|
|
||||||
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
|
|
||||||
and consumer adoption in the [consumer guide](../consumers/README.md).
|
|
||||||
|
|
||||||
## Explicit non-goals and deferred scope
|
|
||||||
|
|
||||||
General environment-variable configuration overlays are not supported. Typed secret
|
|
||||||
references and explicit directory bootstrap are the environment convention. Literal
|
|
||||||
credential configuration and implicit secret-directory discovery are unsupported.
|
|
||||||
|
|
||||||
The following remain deliberately excluded:
|
|
||||||
|
|
||||||
- automatic `conf.d` discovery;
|
|
||||||
- remote imports, arbitrary providers, and additional configuration formats;
|
|
||||||
- custom merge strategies, sequence merging, reset operators, multiple-parent
|
|
||||||
inheritance, and cross-scope references;
|
|
||||||
- named overlay inheritance;
|
|
||||||
- parser-specific or custom structural-decoding extension APIs;
|
|
||||||
- generic dynamic-subtree conversion and module-registry APIs;
|
|
||||||
- automatic schema migrations;
|
|
||||||
- hot reload, runtime resource replacement, and remote resource validation;
|
|
||||||
- a bundled CLI framework or application commands; and
|
|
||||||
- configuration fingerprints.
|
|
||||||
|
|
||||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
|
||||||
explanation commands around the library. Broadening these boundaries requires
|
|
||||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
|
||||||
|
|
||||||
Effective configuration diffs are implemented over frozen successful-load
|
|
||||||
inspections; their current comparison and disclosure contract is in the
|
|
||||||
[configuration contract](../config.md#effective-configuration-diffs), with
|
|
||||||
consumer workflows in the [adoption guide](../consumers/README.md#effective-configuration-diffs).
|
|
||||||
Fingerprints remain deferred.
|
|
||||||
|
|
||||||
Built-in sibling inheritance using `extends` is implemented as described in the
|
|
||||||
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
|
||||||
inheritance between selected overlay files, which remains excluded above.
|
|
||||||
|
|
||||||
## Deferred performance experiments
|
|
||||||
|
|
||||||
The completed performance audit left these internal optimization candidates for
|
|
||||||
separate investigation:
|
|
||||||
|
|
||||||
- branch-local explanation replay instead of the current whole-tree replay;
|
|
||||||
- redesigning the private metadata-copy handoff while preserving detached public
|
|
||||||
records;
|
|
||||||
- caching schema descriptors across loads; and
|
|
||||||
- indexing complex contributor-trace membership while preserving its equality
|
|
||||||
rules and compact linked traces.
|
|
||||||
|
|
||||||
These are not committed implementation work. Reconsider them with evidence that
|
|
||||||
the benefit justifies the ownership, retained-memory, or equivalence risks. The
|
|
||||||
completed audit and implementation evidence are preserved in Git history at
|
|
||||||
`8e91a2b` under `docs/roadmap/`.
|
|
||||||
171
docs/roadmap/future.md
Normal file
171
docs/roadmap/future.md
Normal 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?
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
# Implement configuration input destinations
|
|
||||||
|
|
||||||
Status: complete. Stages 1–12 are implemented and audited. The
|
|
||||||
[feature roadmap](input-destinations.md) owns the approved behavior and rationale; the
|
|
||||||
[consumer request](promptkit-feature-request.md#public-acceptance-scenarios)
|
|
||||||
supplies the public acceptance scenarios. This document owns execution and
|
|
||||||
completion evidence. Planning baseline: `6353598`.
|
|
||||||
|
|
||||||
## Working instructions
|
|
||||||
|
|
||||||
- Start each stage with `AGENTS.md`, [development.md](../development.md), the
|
|
||||||
applicable policies, and working-tree inspection. Use graph-first code
|
|
||||||
discovery. Preserve unrelated changes and previously completed stages.
|
|
||||||
- Implement only that stage and its supporting tests/documentation. Mark it
|
|
||||||
complete here with actual commands and evidence, then commit the nonempty
|
|
||||||
validated unit with a short plain-English message. Subsequent stages remain
|
|
||||||
planned. A stage is not complete solely because its narrow tests pass.
|
|
||||||
- Keep early mechanics private until the complete engine behavior is ready.
|
|
||||||
Root destination fields land in Stage 10. Ordinary root loading must remain
|
|
||||||
working throughout. Do not publish a partially supported public placement API.
|
|
||||||
- Follow the existing engine/document/YAML boundaries. Reuse strict composition,
|
|
||||||
recursive overlay, sibling inheritance, paths, history, and sensitivity.
|
|
||||||
No dependencies, toolchain changes, alternate mergers, mount registry,
|
|
||||||
per-fragment lifecycle, import-destination syntax, resource discovery, or new
|
|
||||||
release gates are needed. These are scope boundaries, not new prohibitions
|
|
||||||
on separately approved future work.
|
|
||||||
- Update current-behavior documentation when public behavior lands; keep future
|
|
||||||
claims here or in the roadmap until then. Add the ADR in Stage 1 under the
|
|
||||||
documentation policy's accepted-decision exception.
|
|
||||||
- Use focused offline tests while iterating; run `./scripts/check.sh` before
|
|
||||||
committing production changes. Documentation-only stages need whitespace and
|
|
||||||
local-link checks. Stage 12 adds full ownership/race validation. Tests protect
|
|
||||||
observable behavior, not wrapper allocation or helper-call counts. Reuse
|
|
||||||
coverage rather than repeat an entire loading matrix at every layer.
|
|
||||||
- No benchmark campaign or public complexity guarantee is required. Prefer
|
|
||||||
straightforward bounded walks and wrapping each acquired contribution once;
|
|
||||||
avoid rescanning all preceding contributions for every new input.
|
|
||||||
- Do not push, tag, publish, update downstream dependencies, or retire unfinished
|
|
||||||
records. Release compatibility review is separate from publication.
|
|
||||||
|
|
||||||
## Resolved implementation decisions
|
|
||||||
|
|
||||||
1. Add root-owned `Input.Destination Path` and `SourceInfo.Destination Path`,
|
|
||||||
mirrored privately in engine input/source records. Extend `loadInput` and
|
|
||||||
explicit root/engine conversion and cloning. No new loading entry point,
|
|
||||||
public wrapper type, option, or origin field is needed. Ordinary `Load`
|
|
||||||
continues to supply root placement.
|
|
||||||
2. The zero path means root. Validate `Path.valid()` and reject every explicit
|
|
||||||
index segment, including a negative index. Normalize parsed tokens to literal
|
|
||||||
mapping-key segments: `/0` means the key `0`, not sequence element zero.
|
|
||||||
Empty, dot, dot-dot, slash-containing, tilde-containing, and numeric keys keep
|
|
||||||
existing semantic-path meaning. Do not split dots, interpret filesystem
|
|
||||||
traversal, enforce identifier grammar, or add namespace uniqueness.
|
|
||||||
3. Reject destination segments equal to `configkit` or `extends`: placement
|
|
||||||
would introduce a reserved mapping key with a mapping value. Substrings such
|
|
||||||
as `myextends` are ordinary keys. This preserves the approved structural
|
|
||||||
rules rather than adding an application naming policy. Source-root import
|
|
||||||
directives are still processed before placement.
|
|
||||||
4. Validate destination shape/reservations for all declared inputs during
|
|
||||||
grouped option preflight, before reading any source. Reuse
|
|
||||||
`CodeOptionsInvalid` for invalid destination declarations; include the
|
|
||||||
offending semantic path when representable, with no fabricated physical
|
|
||||||
location. An invalid source still follows existing source-option validation.
|
|
||||||
A valid destination on `NoSource` contributes no nodes, source record, or
|
|
||||||
budget charges. No extra error type or exact message contract is introduced.
|
|
||||||
5. Separate acquired source syntax from application placement. Retain original
|
|
||||||
parsed source metadata for filesystem/directive handling. Strip source-root
|
|
||||||
directives, then wrap each application body at the normalized destination
|
|
||||||
before fragment version checks and strict composition. Imports inherit the
|
|
||||||
same destination transitively. Do not wrap both a source application and its
|
|
||||||
recomposed result; every retained application must already use effective
|
|
||||||
coordinates. Content roots remain mappings under existing source contracts.
|
|
||||||
6. Wrapping a body at `Keys("a", "b")` creates `{a: {b: body}}` as private
|
|
||||||
document mappings. Actual body nodes and entries keep their physical source
|
|
||||||
coordinates. Introduced mappings/entries use the contributing document's root
|
|
||||||
location as a documented source fallback, not purported authored coordinates.
|
|
||||||
They have the same contributing source ID and create no synthetic source.
|
|
||||||
Retained history trees remain immutable and separate from assembly mutation.
|
|
||||||
7. A present empty body creates explicit destination mappings, including an
|
|
||||||
import-only source whose application body is empty. It still cannot clear
|
|
||||||
lower mappings. A missing optional import and `NoSource` remain absent. Empty
|
|
||||||
source text remains the existing invalid document, not an empty mapping.
|
|
||||||
8. `VersionRule.Path` stays an effective application path. Check main once after
|
|
||||||
its own imports compose and before underlays/overlay/CLI, at the existing
|
|
||||||
phase. Imported and underlay claims are checked against placed application
|
|
||||||
content, including scalar/null/sequence ancestors replacing a protected path.
|
|
||||||
A mapping ancestor with no protected descendant is not a version claim.
|
|
||||||
Mounted domain `version` fields are ordinary data. Existing inheritance,
|
|
||||||
selected-overlay, CLI protection and exact-path numeric conversion remain;
|
|
||||||
no nested blanket conversion or additional fragment checker is introduced.
|
|
||||||
9. Placement does not alter physical source identity, filesystem namespace,
|
|
||||||
source IDs, acquisition order, imported-from locations, or resource contexts.
|
|
||||||
Repeated physical sources in one strict group still fail even at different
|
|
||||||
destinations. The same physical source in separate tiers retains the existing
|
|
||||||
independent source applications/records and each record's own destination.
|
|
||||||
Overlay and CLI source records have root `Destination`; they are not mounted
|
|
||||||
merely because main is mounted. Active layered overlays retain their explicit
|
|
||||||
independent filesystem selection and application-root meaning.
|
|
||||||
10. Value-level paths use effective coordinates. Keep genuine source-local
|
|
||||||
import-directive diagnostics and parse/read failures at their original
|
|
||||||
locations; do not invent a logical child path when parsing cannot identify
|
|
||||||
one. Prefix existing application-relative diagnostic paths exactly once
|
|
||||||
where available. Preserve structured codes, related physical locations,
|
|
||||||
deterministic ordering, and value-free messages.
|
|
||||||
11. Use current depth/node limits, not a new destination cap. With root depth
|
|
||||||
zero, each destination segment adds one level and, under `countInputNodes`,
|
|
||||||
one mapping node plus one key. Retain current raw input accounting (including
|
|
||||||
directives); charge only the additional `2 * segmentCount` synthetic nodes
|
|
||||||
per acquired body, once, to the shared input budget. Placement adds no bytes,
|
|
||||||
acquired sources, import nesting, or glob visits. Validate placed application
|
|
||||||
depth and per-document node bounds before composition; share accounting
|
|
||||||
across inputs and imports. Precheck wrapper depth/count before allocating
|
|
||||||
wrappers. Use existing source/import limit diagnostics in their acquisition
|
|
||||||
contexts; retain projection, expansion, and history bounds independently.
|
|
||||||
Root placement adds no charge or new rejection. NoSource adds none either.
|
|
||||||
12. Cloned source records include independently owned destination paths at every
|
|
||||||
existing publication boundary: results, load errors, metadata/inspection,
|
|
||||||
callback input information, and root conversion. Passive resource contexts
|
|
||||||
remain keyed by the real source identity; inspection must work without an
|
|
||||||
open configuration handle after load. This does not extend caller-owned
|
|
||||||
filesystem or callback resource lifetimes.
|
|
||||||
|
|
||||||
## Stage 1 — Record the decision and capture the baseline
|
|
||||||
|
|
||||||
Status: complete. Scope: decision record and evidence, no feature code.
|
|
||||||
|
|
||||||
1. Read the roadmap, request, relevant policies, and ADRs 0001, 0003, 0006,
|
|
||||||
0008, and 0009. Inspect the owners identified in the roadmap and the function
|
|
||||||
map below. Confirm the baseline and worktree before starting.
|
|
||||||
2. Run `./scripts/check.sh`; record the commit, Go version, and results here.
|
|
||||||
Locate existing coverage for root loading, strict underlays, version ownership,
|
|
||||||
repeated sources, history, resource contexts, and detached records.
|
|
||||||
3. Write `docs/adr/0011-input-destinations.md` after confirming the number is
|
|
||||||
unused. Record the approved input-placement decision as Accepted, with the
|
|
||||||
roadmap owning implementation status. Follow the repository ADR format.
|
|
||||||
Focus on ConfigKit, put rejected options only in Alternatives Considered,
|
|
||||||
and avoid negative parallelism elsewhere. No PromptKit-specific semantics.
|
|
||||||
4. Record the compatibility assessment under the release policy: adding fields
|
|
||||||
preserves zero-value/keyed-literal use, but callers using unkeyed literals
|
|
||||||
for `Input` or `SourceInfo` must migrate to keyed literals. Treat the feature
|
|
||||||
as minor-release work; select a version and write its note only during
|
|
||||||
separately authorized release preparation.
|
|
||||||
|
|
||||||
Validated: `./scripts/check.sh` (go1.26.8 linux/amd64) passed on candidate
|
|
||||||
`d3d468a8b47d780787fca329ff65b107d98ca59f`; this includes formatting,
|
|
||||||
whitespace, local documentation links, tests, vet, and build. The planning
|
|
||||||
baseline `6353598bd6cce2eb78a5fe990e144cc0cadf1280` is present in history. The
|
|
||||||
worktree was clean before this stage.
|
|
||||||
|
|
||||||
Coverage located for later destination work:
|
|
||||||
|
|
||||||
- Root and equivalent source loading: `TestEquivalentSourceFormsPreserveInputSemantics`
|
|
||||||
(`facade_equivalence_test.go`) and
|
|
||||||
`TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad` (`facade_layered_test.go`).
|
|
||||||
- Strict underlays and group budgets: `TestGroupedLoadStrictConflictsRepetitionAndBudgets`
|
|
||||||
and `TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle`
|
|
||||||
(`internal/engine/load_request_test.go`).
|
|
||||||
- Version ownership and imported claims: `TestLoadRejectsImportConflictsAndFragmentVersions`
|
|
||||||
(`internal/engine/imports_test.go`) and version-path coverage in
|
|
||||||
`internal/engine/load_test.go`.
|
|
||||||
- Repeated physical sources: `TestRepeatedImportsAcrossTiersAndReaderConsumption`
|
|
||||||
(`facade_equivalence_test.go`).
|
|
||||||
- History and source contexts: `TestUnderlayHistoryRetainsLaterMappingContributors`
|
|
||||||
(`facade_underlay_origins_test.go`) and
|
|
||||||
`TestFacadeSourceContextsFollowOriginsWithoutQueryIO`
|
|
||||||
(`facade_resource_context_test.go`).
|
|
||||||
- Detached records: `TestLoadMetadataReflectsDefaultsInputAndDetachedRecords`
|
|
||||||
(`internal/engine/load_test.go`) and
|
|
||||||
`TestMetadataQueriesReturnDetachedRecords` (`internal/engine/metadata_test.go`).
|
|
||||||
|
|
||||||
The decision is recorded in [ADR 0011](../adr/0011-input-destinations.md).
|
|
||||||
Compatibility assessment: adding the two public fields breaks callers using
|
|
||||||
unkeyed literals for `Input` or `SourceInfo`; keyed and zero-value use remains
|
|
||||||
compatible. The release policy permits the intentional pre-1.0 contract change
|
|
||||||
in a minor release when the release note states impact and migration. Version
|
|
||||||
selection and release-note preparation remain out of scope until separately
|
|
||||||
authorized. No feature code or current behavior contract changed.
|
|
||||||
|
|
||||||
## Stage 2 — Add private destination normalization and wrapping
|
|
||||||
|
|
||||||
Status: complete. Scope: pure private mechanics with bounded unit tests.
|
|
||||||
|
|
||||||
1. Add a small engine helper file for normalized mapping-only destinations and
|
|
||||||
constructing placed document bodies. Follow decisions 2–7; keep helpers
|
|
||||||
private and avoid introducing a second path abstraction.
|
|
||||||
2. Validate invalid paths, explicit indices, and exact reserved tokens. Normalize
|
|
||||||
parsed numeric tokens into keys, preserving literal text and pointer escaping.
|
|
||||||
3. Build wrappers without mutating the supplied tree. Preserve body locations;
|
|
||||||
give introduced entries/mappings the source-root fallback. Implement depth and
|
|
||||||
node measurement/prechecks usable with existing limits, including the extra
|
|
||||||
synthetic-node count; aggregate charging is wired in Stage 7.
|
|
||||||
4. Cover root identity/equivalence, nested empty mapping, literal/escaped/numeric
|
|
||||||
keys, invalid index/path, exact versus substring reserved keys, owned paths,
|
|
||||||
unchanged body coordinates, and bounded wrapper checks. Do not count helper
|
|
||||||
invocations or fix an allocation strategy in tests.
|
|
||||||
|
|
||||||
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument'`
|
|
||||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. The private
|
|
||||||
helpers normalize mapping-only destinations, preserve authored nodes and source
|
|
||||||
coordinates, and check per-document depth/node bounds before allocating wrappers.
|
|
||||||
No loader accepts a new public field yet.
|
|
||||||
|
|
||||||
## Stage 3 — Integrate private non-import input placement
|
|
||||||
|
|
||||||
Status: complete. Scope: engine request plumbing and ordinary acquired bodies.
|
|
||||||
|
|
||||||
1. Add destination fields to engine `Input`, `loadInput`, and `SourceInfo`.
|
|
||||||
Preserve ownership through engine `LoadLayered` request construction,
|
|
||||||
`cloneSourceInfo`, and source-record copies. Keep the root facade unchanged.
|
|
||||||
2. Extend grouped preflight to validate/normalize destinations before source
|
|
||||||
reads. Use the existing diagnostic accumulator and options-invalid category.
|
|
||||||
Ordinary `Load` supplies root placement through its existing request adapter.
|
|
||||||
3. Thread placement into `assembleBaseInputTracked` without confusing raw
|
|
||||||
`parsedSource` with effective `assembledInput.document`. Preserve the root
|
|
||||||
path through existing wrappers/callers. For imports-disabled acquisition,
|
|
||||||
validate disabled directives on raw content, place the body, and retain the
|
|
||||||
destination on the acquired source record. NoSource still short-circuits.
|
|
||||||
4. Use engine loads to cover mounted main and underlay bodies, omitted versus
|
|
||||||
explicit root destinations, empty mapping versus NoSource, and preflight
|
|
||||||
rejection without consuming a test-owned reader. Reuse current source-kind
|
|
||||||
acquisition tests; full source equivalence is verified publicly in Stage 10.
|
|
||||||
|
|
||||||
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Grouped'`
|
|
||||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Private non-import
|
|
||||||
placement reaches assembly, with root behavior unchanged. Imports are the next
|
|
||||||
private integration step.
|
|
||||||
|
|
||||||
## Stage 4 — Carry placement through import acquisition
|
|
||||||
|
|
||||||
Status: complete. Scope: source-local directives and placed imported applications.
|
|
||||||
|
|
||||||
1. Thread a normalized destination through `loadImportedDocumentsTracked`,
|
|
||||||
`loadImportedWithAccessTracked`, and `importTraversal`, retaining root defaults
|
|
||||||
for current private callers. Place the selected body's application content
|
|
||||||
after `splitImportDirectives`; do the same for every imported body.
|
|
||||||
2. Set placement on selected/imported source records, including error records
|
|
||||||
once a source has been acquired. Keep `ImportedFrom` at the physical directive.
|
|
||||||
Run imported `fragmentVersionClaim` against placed bodies and retain placed
|
|
||||||
bodies in `state.documents` before `composeDocuments` builds applications.
|
|
||||||
3. Leave filesystem joins, containment, glob traversal, optional missing inputs,
|
|
||||||
active cycles, visited identities, and acquisition IDs/order unchanged.
|
|
||||||
Destination must not become part of the repeated-source identity key.
|
|
||||||
4. Test native and host transitive imports, nested source-root directives,
|
|
||||||
import-only bodies, and mounted imported protected versus ordinary version
|
|
||||||
paths. Verify a repeated file at two destinations in one group still fails,
|
|
||||||
including an import/direct-input alias. Reuse cycle/containment regressions.
|
|
||||||
|
|
||||||
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Import|Grouped'`
|
|
||||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Retained imported
|
|
||||||
contributions have effective placement and physical source identity, with no
|
|
||||||
double wrapping or application-level directive leakage.
|
|
||||||
|
|
||||||
## Stage 5 — Complete strict tiers and main version ownership
|
|
||||||
|
|
||||||
Status: complete. Scope: assembly and version interactions, not a new merger.
|
|
||||||
|
|
||||||
1. Review `acquireUnderlayGroup`: version checks must use placed content, and
|
|
||||||
documents taken from `acquired.composition.applications` must already be
|
|
||||||
placed. Recompose strict peers with ordinary `composeDocuments`; preserve
|
|
||||||
per-source applications, origins, UnderlayIndex, and input accounting.
|
|
||||||
2. In `executeLoadRequest`, main's version check uses its placed main/import
|
|
||||||
composition at the existing point. `assembleLayeredInput` recursively overlays
|
|
||||||
completed underlay groups beneath main, selected overlay, and CLI as before.
|
|
||||||
Do not mount the final assembly or prepend main's destination to upper tiers.
|
|
||||||
3. Test disjoint same-destination peers, different destinations, equal and unequal
|
|
||||||
conflicts, ancestor/descendant destinations, and a strict conflict despite an
|
|
||||||
upper override. Verify recursive mapping overlays, atomic sequences, null,
|
|
||||||
explicit false/zero, and version-only domain overrides retaining lower fields.
|
|
||||||
4. Exercise mounted main with a nested VersionRule, absent root declarations,
|
|
||||||
imported/underlay direct and ancestor claims, and nonclaiming mapping ancestors.
|
|
||||||
Check the hook runs once and does not inspect each fragment independently.
|
|
||||||
Preserve exact configured-path integer conversion and ordinary nested typing.
|
|
||||||
|
|
||||||
Validate: destination/composition/overlay/version/grouped tests and everyday
|
|
||||||
check. Validated with `go test ./internal/engine -run 'LayeredDestination'`,
|
|
||||||
`go test ./internal/engine -run 'Destination|Composition|Overlay|Version|Grouped'`,
|
|
||||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The new mounted
|
|
||||||
cases verify strict same-tier conflicts and disjoint composition, independent
|
|
||||||
destinations and nested paths, recursive/atomic overlay behavior, explicit
|
|
||||||
null/false/zero, and main-only nested version checking after imports compose.
|
|
||||||
Done: placed and authored equivalents agree on values and conflicts; main
|
|
||||||
ownership and lifecycle timing are preserved.
|
|
||||||
|
|
||||||
## Stage 6 — Reconcile diagnostic coordinate systems
|
|
||||||
|
|
||||||
Status: complete. Scope: useful paths without fictional source locations.
|
|
||||||
|
|
||||||
1. Audit early acquisition, import, fragment-version, and composition failures.
|
|
||||||
Use one narrow path-prefix translation where diagnostics genuinely identify
|
|
||||||
application content; do not blanket-prefix every diagnostic or double-prefix
|
|
||||||
diagnostics already produced by placed trees.
|
|
||||||
2. Preserve syntax/read/directive locations and related import sites. Value
|
|
||||||
conflicts and later typed/resolver/validation failures use effective paths
|
|
||||||
but original content coordinates. Invalid introduced structure identifies
|
|
||||||
the destination declaration rather than a nonexistent source wrapper line.
|
|
||||||
3. Add focused assertions for a mounted conflict with two real sources, a mounted
|
|
||||||
decode or validation error, an imported version error, invalid destination,
|
|
||||||
and malformed source-local directives. Test stable codes/paths/locations and
|
|
||||||
nil results, not whole messages or callback choreography.
|
|
||||||
|
|
||||||
Validate: destination/diagnostic/import/version/decode tests and everyday check.
|
|
||||||
Validated with `go test ./internal/engine -run 'LayeredDestinationDiagnostics'`,
|
|
||||||
`go test ./internal/engine -run 'Destination|Diagnostic|Import|Version|Decode'`,
|
|
||||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The mounted
|
|
||||||
conflict, decode, and imported-version assertions use effective application
|
|
||||||
paths with authored source locations; invalid destinations have no physical
|
|
||||||
location; malformed source-root directives retain their authored path and
|
|
||||||
location. Later value diagnostics are produced from placed trees, while directive
|
|
||||||
diagnostics retain their raw source coordinates. Post-review remediation also
|
|
||||||
rebases known Mapping value-builder failure paths, which are produced before
|
|
||||||
placement. `TestFacadeDestinationMappingFailurePaths` covers mounted main and
|
|
||||||
underlay failures, escaped keys and sequence-index intent, cycles, unchanged
|
|
||||||
root paths, and unchanged source-constructor failures. Its mounted value cases
|
|
||||||
failed before the fix and pass afterward.
|
|
||||||
Done: diagnostics consistently distinguish application paths from source syntax.
|
|
||||||
|
|
||||||
## Stage 7 — Complete shared placement accounting
|
|
||||||
|
|
||||||
Status: complete. Scope: resource bounds with no new public limit system.
|
|
||||||
|
|
||||||
1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary
|
|
||||||
acquisition and import traversal, using `importAccounting` and current limit
|
|
||||||
structures. Charge each source body once, including empty import bodies.
|
|
||||||
2. Preserve existing raw byte/source/node accounting and imported directive
|
|
||||||
inspection. Additional wrappers count in per-body depth/node checks and
|
|
||||||
shared aggregate nodes; recomposition must not charge them again. Ensure
|
|
||||||
selected overlays and CLI start from totals including placement charges.
|
|
||||||
3. Test below/at/above relevant bounds using small private limit fixtures where
|
|
||||||
existing seams permit; add a narrow private seam if necessary. Cover deep
|
|
||||||
body plus destination, many shallow mounted inputs, imported wrappers, and
|
|
||||||
empty mappings. Verify root placement and NoSource do not add charges.
|
|
||||||
Preserve history/expansion limits; do not turn fixture values into defaults.
|
|
||||||
|
|
||||||
Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'`
|
|
||||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in
|
|
||||||
`TestDestinationPlacementCountsAgainstSharedNodeBudget`,
|
|
||||||
`TestMultipleMountedInputsSharePlacementCharges`,
|
|
||||||
`TestImportedEmptyBodiesChargeEachDestinationWrapper`,
|
|
||||||
`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and
|
|
||||||
`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and
|
|
||||||
above the boundary, multiple shallow inputs, imported empty bodies, and charging
|
|
||||||
of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues
|
|
||||||
to cover per-document depth/node boundaries; existing grouped accounting tests
|
|
||||||
cover unchanged raw source/byte charging. Done: placement cannot bypass current
|
|
||||||
budgets and introduces no unrelated caps.
|
|
||||||
|
|
||||||
## Stage 8 — Preserve provenance, history, and source resource contexts
|
|
||||||
|
|
||||||
Status: complete. Scope: engine inspection and retained evidence.
|
|
||||||
|
|
||||||
1. Audit `compositionOrigins`, `completeCompositionOrigins`, application replay,
|
|
||||||
metadata construction/finalization, and source-record cloning against placed
|
|
||||||
applications. Prefer feeding placed trees into existing processing rather
|
|
||||||
than adding another path-offset index or rewriting final metadata afterward.
|
|
||||||
2. Verify explicit destination containers, actual leaves, composite origins,
|
|
||||||
superseded/removed descendants, and root/source fallbacks. Historical and
|
|
||||||
effective indexes keep independently owned paths and existing shape semantics.
|
|
||||||
3. Preserve source-ID resource contexts through imports, surviving lower-tier
|
|
||||||
values, and inheritance. Test completed-result and resolution-time context
|
|
||||||
queries against real relative files, without making ConfigKit open the
|
|
||||||
referenced application resource or retain loading handles.
|
|
||||||
4. Exercise the same physical source in separate tiers with different placement,
|
|
||||||
inspect each source record, and verify independent destination paths. Repeat
|
|
||||||
inspection after comparison and independent loads to detect retained mutation.
|
|
||||||
|
|
||||||
Validated with `go test ./internal/engine -run 'Destination|Metadata|Provenance|History|Resource|SourceContext'`,
|
|
||||||
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
|
|
||||||
linux/amd64); all passed. `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`
|
|
||||||
checks synthetic-container fallback locations, authored leaf coordinates,
|
|
||||||
composite origins, removed-descendant history, and the same host file retained
|
|
||||||
at distinct destinations in separate tiers before and after comparison and an
|
|
||||||
independent load. `TestLayeredImportedInheritedValuesRetainResourceContexts`
|
|
||||||
uses real temporary files to verify resolution-time and completed-result
|
|
||||||
relative-resource lookup through an imported surviving parent and its inherited
|
|
||||||
child. Existing metadata and resource-context tests continue to cover detached
|
|
||||||
records and loading-handle closure. Done: inspection explains effective
|
|
||||||
placement and physical origin without consumer reconstruction.
|
|
||||||
|
|
||||||
## Stage 9 — Verify final inheritance, sensitivity, and lifecycle integration
|
|
||||||
|
|
||||||
Status: complete. Scope: complete private engine behavior before facade exposure.
|
|
||||||
|
|
||||||
1. Resolve sibling inheritance only after full source precedence using the
|
|
||||||
existing expansion path. Cover a child and parent supplied by separate peers,
|
|
||||||
nested destination-local siblings, cancellation, and an inherited relative
|
|
||||||
resource retaining its declaring context. Cross-collection search is unchanged.
|
|
||||||
2. Reuse existing inheritance-version, overlay, and CLI guards; add targeted
|
|
||||||
mounted cases for actual protected paths and their ancestors. Do not reject
|
|
||||||
unrelated inheritance merely because it shares a mapping ancestor.
|
|
||||||
3. Test effective-path secret predicates, field tags and recorded dependencies,
|
|
||||||
superseded classified values, safe effective output/history/explanation, and
|
|
||||||
bilateral diff redaction using disposable canaries. Compare mounted versus
|
|
||||||
explicitly wrapped loads for equal effective values and no differences.
|
|
||||||
4. Verify defaults, decoding, resolution, secret lookup, and validation execute
|
|
||||||
once on the final shape; failures return no usable result. Reuse existing
|
|
||||||
ordinary-load lifetime/ownership coverage and add mounted protection where
|
|
||||||
placement creates a distinct risk.
|
|
||||||
|
|
||||||
Validate: destination/inheritance/secret/sensitivity/explain/diff/lifecycle tests
|
|
||||||
and everyday check. Validated with
|
|
||||||
`go test ./internal/engine -run 'Destination|Inheritance|Secret|Sensitivity|Explain|Diff|Load|Version|Grouped' -count=1`,
|
|
||||||
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
|
|
||||||
linux/amd64); all passed.
|
|
||||||
|
|
||||||
`TestLayeredDestinationInheritanceUsesFinalSiblingScopes` verifies parent and
|
|
||||||
child contributions from separate peers, a higher-tier parent update, nested
|
|
||||||
destination-local siblings, and cancellation. `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`
|
|
||||||
keeps sibling lookup local. `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers`
|
|
||||||
protects inherited values, scalar/null ancestors, consumed directives, selected
|
|
||||||
overlays, and CLI overrides at mounted paths while allowing nonclaiming mapping
|
|
||||||
ancestors and unrelated inheritance. The coverage exposed an ancestor gap in
|
|
||||||
the existing inheritance-version check; it now applies the shared fragment claim
|
|
||||||
logic to the transferred parent subtree.
|
|
||||||
|
|
||||||
`TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape` checks one
|
|
||||||
defaults/resolution/secret-lookup/validation lifecycle, final mounted decoding,
|
|
||||||
field tags and effective-path predicates, superseded history, and safe effective
|
|
||||||
output/explanation against canaries; it compares mounted and explicitly wrapped
|
|
||||||
loads for equal configuration and no diff. `TestLayeredDestinationDiffUsesBilateralSensitivity`
|
|
||||||
checks redaction from either side and recorded dependencies, and
|
|
||||||
`TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` checks a
|
|
||||||
mounted decode failure returns no result. Stage 8's
|
|
||||||
`TestLayeredImportedInheritedValuesRetainResourceContexts` continues to verify
|
|
||||||
the declaring context for inherited relative resources. Done: approved engine
|
|
||||||
semantics integrate without per-fragment loading or a post-return processing
|
|
||||||
pass.
|
|
||||||
|
|
||||||
## Stage 10 — Expose the root-owned facade and public acceptance tests
|
|
||||||
|
|
||||||
Status: complete. Scope: the two public fields and complete consumer behavior.
|
|
||||||
|
|
||||||
1. Add `Input.Destination Path` and `SourceInfo.Destination Path` to root types.
|
|
||||||
Extend `toEngineInput`, `fromEngineSourceInfo`, `toEngineSourceInfo`, and all
|
|
||||||
source publication paths with owned conversion. Run existing public/import
|
|
||||||
boundary tests; expose no engine nodes, aliases, or bridge methods.
|
|
||||||
2. Document zero/root behavior, mapping-only paths, main/underlay parity,
|
|
||||||
inherited import placement, source-coordinate fallback, and source ownership
|
|
||||||
beside declarations. Update the configuration contract for implemented input
|
|
||||||
destinations, preserving canonical ownership of shared semantics.
|
|
||||||
3. Add external-package public-load coverage for equivalent host file, native
|
|
||||||
fs.FS file, byte, reader, and Mapping sources. Cover default/root destinations,
|
|
||||||
literal escaped/numeric keys, empty mapping, NoSource, and invalid declarations.
|
|
||||||
Include one public mounted main case, not just underlays.
|
|
||||||
4. Add representative public tests tying final configuration to metadata,
|
|
||||||
source placement, history, resources, callbacks, and safe diffs. Mutate returned
|
|
||||||
record copies and repeat queries/loads to confirm detachment. Reuse engine
|
|
||||||
matrices; public tests demonstrate supported integration rather than every
|
|
||||||
private branch. Confirm failed loads expose detached source destinations.
|
|
||||||
|
|
||||||
Validated with `go test . -run 'Destination|FacadeLayered|PublicFacadeBoundary|DependencyImportBoundary' -count=1`,
|
|
||||||
`go test -race ./...`, and `./scripts/check.sh` (go1.26.8 linux/amd64); all
|
|
||||||
passed.
|
|
||||||
|
|
||||||
`TestFacadeInputDestinationsAcrossSourceKinds` exercises host, native
|
|
||||||
filesystem, bytes, reader, and mapping inputs with literal escaped and
|
|
||||||
numeric-looking mapping keys. `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths`
|
|
||||||
covers omitted/empty root placement, empty mapping presence, absent `NoSource`,
|
|
||||||
invalid index/reserved destinations, and reader preflight. `TestFacadeImportedSourcesRetainInputDestination`
|
|
||||||
checks public transitive import placement. `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff`
|
|
||||||
ties mounted main/underlay values to metadata, both source destinations, callback
|
|
||||||
source context, relative-resource access during and after loading, history, safe
|
|
||||||
effective/explanation rendering, and bilateral diff redaction; it also mutates
|
|
||||||
returned records and confirms later queries and comparisons remain unchanged.
|
|
||||||
`TestFacadeFailedLoadExposesDetachedSourceDestination` verifies failure records,
|
|
||||||
and `TestSourceInfoDestinationConversionsAndCopies` covers root/engine source
|
|
||||||
record conversion. Existing public-boundary tests pass with the new root-owned
|
|
||||||
fields. Done: destination selection and inspection are available through the
|
|
||||||
supported ConfigKit import path.
|
|
||||||
|
|
||||||
## Stage 11 — Add maintained simple and collection-style consumer examples
|
|
||||||
|
|
||||||
Status: complete. Scope: executable adoption examples with consumer-owned types.
|
|
||||||
|
|
||||||
1. Add a minimal mounted-fragment example using `Input.Destination` in an
|
|
||||||
ordinary layered load. Show literal keys and root-default compatibility
|
|
||||||
without introducing consumer parsing or generic merge helpers.
|
|
||||||
2. Add one representative collection assembly under `examples/consumers`: peer
|
|
||||||
prompt/profile fragments, collection metadata with ordinary `version`, a
|
|
||||||
source-root import, local sibling inheritance, and an intentional higher-tier
|
|
||||||
version-only override retaining the body. Keep the application VersionRule
|
|
||||||
main-owned. Use a controlled native filesystem or temporary files.
|
|
||||||
3. Demonstrate effective-path inspection plus source-relative resource lookup
|
|
||||||
during resolution and from the result, including an inherited/surviving field.
|
|
||||||
Application code owns opening/interpreting the resource and any qualified IDs.
|
|
||||||
4. Add tests for final values, presence, actual source placement/coordinates,
|
|
||||||
resource context, and expected conflict behavior. Link maintained examples
|
|
||||||
from the consumer guide; add a small executable root example if useful for
|
|
||||||
discovery without duplicating the complete scenario.
|
|
||||||
|
|
||||||
Validated with `go test ./examples/consumers -count=1`,
|
|
||||||
`go test . -run '^Example' -count=1`, and `./scripts/check.sh` (go1.26.8
|
|
||||||
linux/amd64); all passed, including local documentation links.
|
|
||||||
|
|
||||||
`Example_inputDestination` demonstrates a literal mounted mapping path and an
|
|
||||||
ordinary root input without consumer-side parsing or merging.
|
|
||||||
`LoadCollectionBundle` and `TestCollectionBundleAssemblesMountedPeersAndResources`
|
|
||||||
exercise prompt/profile peer sources, imported collection metadata with an
|
|
||||||
ordinary version label, main-owned schema-version checking, local sibling
|
|
||||||
inheritance, and a higher-tier prompt version-only change that retains the rest
|
|
||||||
of its body. The resolver and `ReadCollectionPromptContent` follow direct and
|
|
||||||
inherited `content_file` provenance to the declaring native-filesystem context.
|
|
||||||
The test checks final values, explicit presence, source destinations and authored
|
|
||||||
coordinates, relative resources, and the inherited field's origin.
|
|
||||||
`TestCollectionPeerPromptConflictsRemainStrict` preserves expected conflict
|
|
||||||
behavior. The examples are linked from the consumer adoption guide. Done: simple
|
|
||||||
and representative consumers substantiate the same model without a compensating
|
|
||||||
structural pass.
|
|
||||||
|
|
||||||
## Stage 12 — Reconcile documentation and audit the complete implementation
|
|
||||||
|
|
||||||
Status: complete. Scope: final integration audit and evidence, not release work.
|
|
||||||
|
|
||||||
1. Update README navigation, consumer guidance, the component overview, and
|
|
||||||
development reading/test maps. Add ADR 0011 to navigation. Update architecture
|
|
||||||
policy only as needed to recognize placement within existing assembly and
|
|
||||||
source ownership, linking precise behavior to the configuration contract.
|
|
||||||
Do not change unrelated policies, deferred boundaries, or published notes.
|
|
||||||
2. Audit every resolved decision, roadmap behavior, and request acceptance row
|
|
||||||
against implementation and tests. Record the corresponding test names here;
|
|
||||||
for reused coverage explain the behavior it proves. Do not mark complete
|
|
||||||
based on green tests that do not exercise mounting.
|
|
||||||
3. Review the complete baseline-to-candidate diff for accidental strengthening:
|
|
||||||
extra naming rules, namespace uniqueness, main-only restrictions, changed
|
|
||||||
merge/version semantics, invented source-read ordering guarantees, new limits,
|
|
||||||
or expanded secret policies. Remove temporary helpers/unsupported branches
|
|
||||||
left by staged integration. Confirm original source coordinates remain real.
|
|
||||||
4. Run `./scripts/check.sh` and
|
|
||||||
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...`. Review source-kind and
|
|
||||||
placement ownership coverage, error disclosure, complete-on-return handling,
|
|
||||||
and root/private boundaries. Record commit, Go version, results, and any
|
|
||||||
limitations. No release command, CI waiting, benchmark, or downstream upgrade.
|
|
||||||
5. Mark this plan and the roadmap implemented only when code, public examples,
|
|
||||||
and owning contracts agree. Retain completed planning evidence until separately
|
|
||||||
authorized release preparation.
|
|
||||||
|
|
||||||
Audit evidence for the approved decisions and request acceptance surface:
|
|
||||||
|
|
||||||
| Behavior reviewed | Evidence and what it proves |
|
|
||||||
| --- | --- |
|
|
||||||
| Root-owned `Input.Destination` and `SourceInfo.Destination`; detached conversions and publication | `TestSourceInfoDestinationConversionsAndCopies`, `TestFacadeFailedLoadExposesDetachedSourceDestination`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` verify root/engine conversion, error records, and returned record detachment. `TestPublicFacadeBoundary` and `TestDependencyImportBoundary` verify no engine types cross the facade. |
|
|
||||||
| Literal mapping keys, parsed numeric keys, reserved structural tokens, and invalid-path preflight | `TestNormalizeDestinationUsesLiteralMappingKeys`, `TestNormalizeDestinationRejectsIndicesAndReservedKeys`, and `TestLayeredLoadPreflightsDestinationsBeforeReadingSources` cover mapping-only normalization and rejection before reader consumption; `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths` verifies the public error boundary. |
|
|
||||||
| Placement of acquired bodies, empty mappings versus absence, imports, all source kinds, physical identity, and authored coordinates | `TestFacadeInputDestinationsAcrossSourceKinds` covers host/native/bytes/reader/mapping sources; `TestFacadeSameFragmentMountsAtSeparateCollectionDestinations` proves independent public collections can retain the same local key. `TestLayeredImportOnlyInputMaterializesDestination`, `TestLayeredHostImportsInheritInputDestinationTransitively`, `TestLayeredNativeFilesystemImportsInheritInputDestination`, and `TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity` cover inherited import placement and unchanged identity. `TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates`, `TestPlaceDocumentPreservesAuthoredEntryCoordinates`, and `TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations` distinguish fallback wrapper locations from authored coordinates. |
|
|
||||||
| Strict composition, explicit-wrapper equivalence, precedence, empty values, and domain `version` overrides | `TestLayeredDestinationStrictCompositionBoundaries`, `TestLayeredDestinationMatchesExplicitlyWrappedInput`, `TestEmptyMountedMappingRetainsLowerChildren`, and `TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics` prove existing group and overlay semantics are retained; the latter includes ordinary field-only updates rather than whole-record replacement. |
|
|
||||||
| Main-owned version checks and imported, underlay, inheritance, overlay, CLI, and ancestor protection | `TestLayeredDestinationChecksVersionOnceOnComposedMain`, `TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths`, `TestLayeredImportedVersionClaimsUseEffectiveDestination`, and `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers` cover effective paths, one main check, protected-path claims/ancestors, and nonclaiming mapping ancestors. `TestLoadVersionDestinationTypesKeepOrdinaryDecoding` preserves exact configured-path conversion and ordinary nested decoding. |
|
|
||||||
| Final-shape sibling inheritance, no cross-collection lookup, one lifecycle, and failure without a usable partial result | `TestLayeredDestinationInheritanceUsesFinalSiblingScopes`, `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, and `TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` exercise these behaviors. Reused inheritance, lifecycle, and callback tests cover generic behavior that destination placement does not alter. |
|
|
||||||
| Effective paths, presence, provenance/history, safe rendering, sensitivity, bilateral diff, and source-relative resources | `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, `TestLayeredDestinationDiffUsesBilateralSensitivity`, `TestLayeredImportedInheritedValuesRetainResourceContexts`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` connect mounted values to inspection. The imported-inheritance test specifically proves the inherited value keeps the declaring source's passive resource context. |
|
|
||||||
| Shared depth/node limits and unchanged root/absence accounting | `TestPlaceDocumentRootAndBounds`, `TestDestinationPlacementCountsAgainstSharedNodeBudget`, `TestMultipleMountedInputsSharePlacementCharges`, `TestImportedEmptyBodiesChargeEachDestinationWrapper`, `TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and `TestNoSourceAndRootPlacementAddNoWrapperCharges` cover per-document and aggregate limits without adding a new limit class. |
|
|
||||||
| Maintained consumer usage and strict peer behavior | `Example_inputDestination`, `TestCollectionBundleAssemblesMountedPeersAndResources`, and `TestCollectionPeerPromptConflictsRemainStrict` verify the simple and representative collection examples against actual values, resources, source records, and conflict behavior. |
|
|
||||||
|
|
||||||
The wider baseline-to-candidate review covered all 38 changed files (`6353598..2d8df22`): production changes are limited to the root facade fields/conversions, private input/import assembly and accounting, source record ownership, and the inheritance version-claim correction described above. The contract and tests do not impose identifier grammar, namespace uniqueness, main-only placement, source-read ordering, new budgets, nested version conversion, or expanded secret policy. The implementation wraps only acquired application bodies after processing source-root directives; placement does not change physical source identity, authored node locations, filesystem resolution, or later-tier semantics. Stage evidence above reuses existing general composition, resource, inheritance, version, lifecycle, sensitivity, and boundary tests where those behaviors are unchanged; mounted-specific cases are named separately.
|
|
||||||
|
|
||||||
Completion record: audited source candidate `2d8df22dd1cb9fde8e60853da3e2260731acf2bb`. On `go1.26.8 linux/amd64`, `./scripts/check.sh` passed, including formatting, whitespace, documentation links, tests, vet, and build. `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` also passed with dependency downloads disabled. No remaining implementation limitations were found; release preparation, CI, benchmark campaigns, and downstream upgrades remain separately scoped.
|
|
||||||
|
|
||||||
Done: one complete layered load supports input destinations with coherent values,
|
|
||||||
diagnostics, inspection, limits, and source ownership across the approved surface.
|
|
||||||
|
|
||||||
## Implementation owner map
|
|
||||||
|
|
||||||
Use graph tools to locate these symbols rather than assume line numbers:
|
|
||||||
|
|
||||||
- Public input forwarding: `layered.go`, `records.go`, `conversion.go`
|
|
||||||
(source conversions), and existing facade tests.
|
|
||||||
- Private request and lifecycle: `internal/engine/layered.go`, `load_request.go`
|
|
||||||
(`loadInput`, `acquireUnderlayGroup`, `assembleLayeredInput`), `load.go`
|
|
||||||
(`executeLoadRequest`), and `load_input.go` (`assembleBaseInputTracked`).
|
|
||||||
- Directives and imports: `directives.go` (`splitImportDirectives`,
|
|
||||||
`fragmentVersionClaim`), and `imports.go` (`visitPath`).
|
|
||||||
- Shared document semantics: `compose.go`, `document_walk.go`
|
|
||||||
(`countInputNodes`), `path.go`, `parse.go`, and `value_operation.go` limits.
|
|
||||||
- Inspection: `metadata.go`, `history.go`, `explain.go`, `resource_context.go`,
|
|
||||||
`inheritance_origin.go`, `render.go`, and `diff.go`; source copies live in
|
|
||||||
engine `records.go`. Confirm current filenames through the component overview.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None required for implementation. The four policy choices are approved; this
|
|
||||||
plan selects API spelling, validation timing/error category, coordinate handling,
|
|
||||||
and accounting within them. Surface a newly discovered incompatibility or scope
|
|
||||||
change with evidence rather than resolving it by silently narrowing the feature
|
|
||||||
or strengthening an unrelated contract.
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# Configuration Input Destinations Roadmap
|
|
||||||
|
|
||||||
Status: implemented. This roadmap records the approved behavior and rationale
|
|
||||||
for the [PromptKit feature request](promptkit-feature-request.md). The
|
|
||||||
[implementation plan](implementation.md) records the completed sequence and
|
|
||||||
audit evidence; the [configuration contract](../config.md#input-destinations)
|
|
||||||
owns current behavior.
|
|
||||||
|
|
||||||
## Targeted end state
|
|
||||||
|
|
||||||
A consumer can assign a mapping destination to a configuration input and perform
|
|
||||||
one ordinary `LoadLayered` call. The input's application content contributes
|
|
||||||
beneath that destination while retaining normal composition, precedence,
|
|
||||||
inheritance, typed loading, diagnostics, and inspection. PromptKit can assemble
|
|
||||||
collections without parsing and rewriting sources, merging values, or rebuilding
|
|
||||||
provenance itself.
|
|
||||||
|
|
||||||
For example, a fragment defining `summarize.alerts` can contribute beneath
|
|
||||||
`collections`, `weatherreporter`, `prompts`. An independent source with the same
|
|
||||||
local key can contribute beneath another collection without conflict. Collection
|
|
||||||
and prompt names remain ordinary application keys, not ConfigKit namespaces.
|
|
||||||
|
|
||||||
The public surface adds an optional `Destination Path` field on `Input`, not a
|
|
||||||
new loading entry point or an operation on completed results. Content-bearing
|
|
||||||
main inputs and underlay inputs support
|
|
||||||
the same destination model; root placement remains the default.
|
|
||||||
|
|
||||||
The following behavior defines the target:
|
|
||||||
|
|
||||||
- Omitted and empty destinations retain root placement. Destinations consist of
|
|
||||||
literal mapping keys, including dots, slashes, and numeric-looking text.
|
|
||||||
Explicit sequence-index destinations are outside the requested capability.
|
|
||||||
- A valid destination on `NoSource` contributes nothing. An actual empty
|
|
||||||
application mapping materializes its destination mappings as explicit input,
|
|
||||||
matching authored wrappers, without clearing lower-tier children.
|
|
||||||
- Existing structural reservations apply to introduced mappings. Destinations
|
|
||||||
that materialize `configkit` or a mapping-valued `extends` contrary to existing
|
|
||||||
contracts fail through the structured loading-error boundary. Keys merely
|
|
||||||
containing those words remain ordinary keys.
|
|
||||||
- Host files, native filesystem files, bytes, readers, and mapping sources all
|
|
||||||
support placement under their existing acquisition and ownership contracts.
|
|
||||||
Filesystem context, import opt-in, and precedence remain independent choices.
|
|
||||||
- Mounted contributions have the same application values and conflict behavior
|
|
||||||
as explicitly wrapped contributions, with accurate original source locations.
|
|
||||||
Disjoint mappings combine; overlapping non-mapping values within a group
|
|
||||||
conflict even when equal. A later tier cannot repair a strict-group conflict.
|
|
||||||
- Different destinations can coexist in one group, and the same destination can
|
|
||||||
occur in different tiers. Ancestor/descendant destinations follow ordinary
|
|
||||||
composition and overlay rules without namespace uniqueness restrictions.
|
|
||||||
Later tiers recursively overlay mappings and replace other values. Empty
|
|
||||||
mappings do not clear lower values; sequences remain atomic.
|
|
||||||
- A prompt's ordinary `version` override changes that field and retains omitted
|
|
||||||
lower fields. It neither identifies a merge record nor replaces a whole prompt.
|
|
||||||
- Source-root `configkit.imports` remains structural. Imported application data
|
|
||||||
inherits the input destination transitively. Import paths still resolve from
|
|
||||||
their declaring source; containment, optionality, cycles, and repeated-source
|
|
||||||
rules remain applicable. Placement does not change physical source identity.
|
|
||||||
- Sibling inheritance runs after assembly in the effective destination scope.
|
|
||||||
Peer fragments can supply a child and its sibling parent. Qualified PromptKit
|
|
||||||
references do not acquire cross-collection inheritance meaning.
|
|
||||||
- Version ownership is enforced at effective application paths. A collection's
|
|
||||||
mounted `version` is ordinary data, while underlay/import contributions to the
|
|
||||||
actual protected path, or replacements of its ancestors, remain invalid.
|
|
||||||
There is one main-owned version check and no blanket nested numeric conversion.
|
|
||||||
- Defaults, decoding, resolution, secret lookup, and validation run once on the
|
|
||||||
assembled application, not independently on fragments. Callbacks and value
|
|
||||||
diagnostics use effective paths. Failures use structured load errors and yield
|
|
||||||
no usable partial result.
|
|
||||||
- Presence, provenance, history, explanations, effective rendering, and diffs
|
|
||||||
agree with the final values. Sensitivity is evaluated at effective application
|
|
||||||
paths under the existing historical and dependency rules.
|
|
||||||
- Existing source records expose placement, including inherited import
|
|
||||||
placement. Introduced ancestor mappings retain the contributing source's
|
|
||||||
identity; any root-location fallback identifies that document, not an authored
|
|
||||||
wrapper. Actual content retains its real source coordinates.
|
|
||||||
- Original source locations and passive resource contexts survive placement,
|
|
||||||
overrides, and inheritance. `Result.SourceContext` and
|
|
||||||
`Resolution.SourceContext` still identify the declaring filesystem and base
|
|
||||||
directory for a field such as `content_file: ./summary.md`.
|
|
||||||
- Destinations and the structure they introduce participate in existing load
|
|
||||||
limits; placement cannot bypass depth or node accounting. Source ownership,
|
|
||||||
complete-on-return loading, and detached result behavior remain intact.
|
|
||||||
|
|
||||||
Import-destination syntax, independent collection specification headers, and
|
|
||||||
cross-collection inheritance are separate possible follow-ups. This work does
|
|
||||||
not add directory discovery, collection installation, remote acquisition,
|
|
||||||
namespace ownership enforcement, dependency solving, live reload, filename-based
|
|
||||||
identity, arbitrary transformations, or new merge strategies. PromptKit owns its
|
|
||||||
collection inventory, qualified IDs, and execution semantics.
|
|
||||||
|
|
||||||
## Implementation baseline (v0.7.1)
|
|
||||||
|
|
||||||
The request reviewed v0.7.1. At that baseline, the relevant boundaries were:
|
|
||||||
|
|
||||||
- [Input](../../layered.go) holds a source, import opt-in, and filesystem context,
|
|
||||||
but no destination. Root types delegate to the engine through the existing
|
|
||||||
[facade boundary](../internal/overview.md).
|
|
||||||
- [Layered loading](../config.md#layered-loading) already provides strict groups,
|
|
||||||
ordered precedence, shared acquisition budgets, and one final lifecycle.
|
|
||||||
[Underlay acquisition](../../internal/engine/load_request.go) gathers source
|
|
||||||
applications and recomposes them into their strict group.
|
|
||||||
- [Input acquisition](../../internal/engine/load_input.go) and
|
|
||||||
[import traversal](../../internal/engine/imports.go) process source-root
|
|
||||||
directives and source identity before final assembly. Imported version claims
|
|
||||||
are checked during traversal; underlay claims are checked before group
|
|
||||||
composition. These currently operate without a destination.
|
|
||||||
- [Version claim checks](../../internal/engine/directives.go) inspect a document
|
|
||||||
using the configured application path. Wrapping only an already acquired
|
|
||||||
result would leave early fragment checks and diagnostic paths incorrect.
|
|
||||||
- [Sibling inheritance](../adr/0009-sibling-inheritance.md) already runs after
|
|
||||||
source precedence and preserves source-to-destination derivation. Placement
|
|
||||||
must feed that existing mechanism rather than resolve fragments independently.
|
|
||||||
- Existing source records identify acquisition and precedence but do not expose
|
|
||||||
input placement. Existing history retains source applications, so moving only
|
|
||||||
the final tree would be insufficient for coherent inspection.
|
|
||||||
|
|
||||||
The architectural fit is an extension of input assembly, not a parser extension
|
|
||||||
or a second configuration model. The
|
|
||||||
[architecture policy](../policy/architecture.md) supplies the ownership and
|
|
||||||
consistency constraints; [deferred scope](deferred.md) continues to govern the
|
|
||||||
unrequested features above.
|
|
||||||
|
|
||||||
## Selected Design Decisions
|
|
||||||
|
|
||||||
### Main-input destinations and absent inputs
|
|
||||||
|
|
||||||
Allow the same destination model for content-bearing main inputs and underlays,
|
|
||||||
with `VersionRule.Path` remaining an effective application path and any
|
|
||||||
specification declaration still owned by main. The checker retains its existing
|
|
||||||
handling of an absent declaration and its existing lifecycle phase. `NoSource`
|
|
||||||
contributes nothing even with a valid destination. This keeps `Input` coherent
|
|
||||||
across its roles and preserves absence without per-fragment version checks.
|
|
||||||
|
|
||||||
### Public placement inspection and synthetic ancestors
|
|
||||||
|
|
||||||
Expose effective placement as `SourceInfo.Destination Path` on existing source
|
|
||||||
records, including inherited import placement.
|
|
||||||
Use original source identity for introduced ancestor mappings, documenting that
|
|
||||||
any root-location fallback identifies the contributing document rather than a
|
|
||||||
physically authored wrapper. Actual content keeps its real coordinates. This
|
|
||||||
allows consumers to connect placement to sources without a new query system.
|
|
||||||
|
|
||||||
Preserve that relationship through inherited fields, composite origins, and the
|
|
||||||
same source selected in separate tiers. Placement is per source application;
|
|
||||||
consumers can inspect it without inferring it from final paths.
|
|
||||||
|
|
||||||
### Reserved structural keys in destinations
|
|
||||||
|
|
||||||
Use existing `Path` conventions for literal keys and parsed
|
|
||||||
numeric tokens, reject explicit index segments, and preserve current structural
|
|
||||||
reservations in the effective tree. Reject destination segments that would
|
|
||||||
materialize `configkit` or a mapping-valued `extends` contrary to those contracts,
|
|
||||||
with a clear structured destination error. Ordinary keys containing those words,
|
|
||||||
dots, or slashes remain ordinary keys. Source-root directives are processed before
|
|
||||||
placement and are not rejected simply because their application body is mounted.
|
|
||||||
|
|
||||||
Mounted and authored wrappers therefore retain the same structural meaning.
|
|
||||||
Destination diagnostics identify placement as the cause of invalid introduced
|
|
||||||
structure, using existing error categories where suitable.
|
|
||||||
|
|
||||||
### Empty mappings and presence of introduced containers
|
|
||||||
|
|
||||||
Mounting an actual empty application mapping materializes its
|
|
||||||
destination mappings as explicit contributions, matching authored wrappers.
|
|
||||||
This preserves the request's equivalence principle and lets consumers inspect
|
|
||||||
an intentionally empty collection. Such mappings still do not clear lower-tier
|
|
||||||
children. An absent source contributes nothing.
|
|
||||||
|
|
||||||
This applies equally to empty imported application bodies and inputs containing
|
|
||||||
only import directives. Presence follows the contribution's meaning rather than
|
|
||||||
whether an implementation happened to allocate wrapper nodes.
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
# PromptKit Feature Request: Configuration Input Destinations
|
|
||||||
|
|
||||||
Status: consumer feature request; not accepted or implemented.
|
|
||||||
|
|
||||||
## Requested Capability
|
|
||||||
|
|
||||||
Provide a public API through which a consumer can select a destination mapping
|
|
||||||
path for each configuration input. The input's application content contributes
|
|
||||||
beneath that path while retaining ConfigKit's normal composition, precedence,
|
|
||||||
inheritance, validation, diagnostics, and inspection behavior.
|
|
||||||
|
|
||||||
PromptKit needs this capability for its proposal 3, **Collections With Mounted
|
|
||||||
Configuration Fragments**, recorded in PromptKit's
|
|
||||||
`docs/roadmap/configuration-collections-mounted-fragments.md`. This request
|
|
||||||
describes the public API needs and observable acceptance criteria. It does not
|
|
||||||
prescribe ConfigKit's internal design or an implementation plan.
|
|
||||||
|
|
||||||
The reviewed public baseline is ConfigKit v0.7.1. The current
|
|
||||||
[layered-loading contract](../config.md#layered-loading) lets consumers choose
|
|
||||||
sources, import policy, filesystem context, and precedence groups, but does not
|
|
||||||
provide an input destination. Existing behavior remains authoritative until an
|
|
||||||
extension is adopted.
|
|
||||||
|
|
||||||
## Consumer Problem
|
|
||||||
|
|
||||||
PromptKit applications assemble independently maintained collections of prompts,
|
|
||||||
inference profiles, and backends. Collections can contain identical local names,
|
|
||||||
such as `summarize.alerts`, without identifying the same resource. PromptKit wants
|
|
||||||
an effective configuration organized by collection, while letting authors omit
|
|
||||||
repeated collection and resource-container wrappers from individual files.
|
|
||||||
|
|
||||||
A file selected from a collection's `prompts` directory would contain:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
summarize.alerts:
|
|
||||||
version: "1"
|
|
||||||
default_profile: provider/brief
|
|
||||||
messages:
|
|
||||||
- role: user
|
|
||||||
content: Summarize the weather alerts.
|
|
||||||
output:
|
|
||||||
format: text
|
|
||||||
validation_mode: none
|
|
||||||
```
|
|
||||||
|
|
||||||
Given destination `[collections, weatherreporter, prompts]`, its contribution
|
|
||||||
would appear in the final application configuration as:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
collections:
|
|
||||||
weatherreporter:
|
|
||||||
prompts:
|
|
||||||
summarize.alerts:
|
|
||||||
version: "1"
|
|
||||||
default_profile: provider/brief
|
|
||||||
messages:
|
|
||||||
- role: user
|
|
||||||
content: Summarize the weather alerts.
|
|
||||||
output:
|
|
||||||
format: text
|
|
||||||
validation_mode: none
|
|
||||||
```
|
|
||||||
|
|
||||||
Another collection can contribute `summarize.alerts` beneath
|
|
||||||
`[collections, homewatch, prompts]` without conflicting. After final handoff,
|
|
||||||
PromptKit interprets these as `weatherreporter/summarize.alerts` and
|
|
||||||
`homewatch/summarize.alerts`. The profile reference is illustrative and must be
|
|
||||||
supplied separately by the application configuration.
|
|
||||||
|
|
||||||
PromptKit will support one installed definition per prompt. Its optional flat
|
|
||||||
`version` is ordinary domain metadata, not a merge identity. This request does
|
|
||||||
not need identified-record sequences, sequence-root documents, or a custom
|
|
||||||
merge policy.
|
|
||||||
|
|
||||||
Authors could write the complete wrappers today, but that repeats assembly
|
|
||||||
details in every fragment. Consumer-side parsing and wrapping would put generic
|
|
||||||
source processing back into PromptKit and complicate source attribution.
|
|
||||||
PromptKit instead wants to select sources and destinations, then receive one
|
|
||||||
final configuration through ConfigKit's supported loading lifecycle.
|
|
||||||
|
|
||||||
## Minimum Public API
|
|
||||||
|
|
||||||
### Per-Input Destination
|
|
||||||
|
|
||||||
Allow a destination to be attached to each `Input` passed to `LoadLayered`.
|
|
||||||
One possible public spelling is an optional `Destination Path` field on `Input`.
|
|
||||||
The name and exact signature are suggestions; equivalent public API designs
|
|
||||||
are welcome.
|
|
||||||
|
|
||||||
Illustrative consumer code using the proposed field, which does not exist in
|
|
||||||
v0.7.1:
|
|
||||||
|
|
||||||
```go
|
|
||||||
promptInput := configkit.Input{
|
|
||||||
Source: configkit.File("/usr/local/share/collections/weatherreporter/prompts/alerts.yml"),
|
|
||||||
Destination: configkit.Keys("collections", "weatherreporter", "prompts"),
|
|
||||||
}
|
|
||||||
|
|
||||||
peers := configkit.Underlay{
|
|
||||||
Inputs: []configkit.Input{promptInput},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The consumer would include `peers` in its existing `LayeredOptions` and invoke
|
|
||||||
`LoadLayered` with its normal root main input and application `Definition`.
|
|
||||||
This is a partial API sketch, not an executable example or a proposed new
|
|
||||||
loading entry point. A single public layered load is sufficient for PromptKit;
|
|
||||||
it does not require a separate mounting operation on completed results.
|
|
||||||
|
|
||||||
The API must let the consumer:
|
|
||||||
|
|
||||||
- Express a destination relative to the application configuration root using
|
|
||||||
literal mapping-key segments. Dots, slashes, and numeric-looking text inside
|
|
||||||
a key are not implicit path separators or array indexes. PromptKit's namespace
|
|
||||||
grammar remains its own domain rule.
|
|
||||||
- Omit the destination to retain existing root placement. An explicitly empty
|
|
||||||
destination should have the same meaning. Mapping destinations are sufficient;
|
|
||||||
sequence-index destinations are not needed by this request.
|
|
||||||
- Assign different destinations to inputs in the same strict composition group,
|
|
||||||
and assign the same destination to inputs in separate precedence groups.
|
|
||||||
- Use the destination with existing content-bearing source kinds: host files,
|
|
||||||
native filesystem files, bytes, readers, and mapping sources, subject to their
|
|
||||||
existing acquisition and ownership contracts.
|
|
||||||
- Retain the existing import opt-in and filesystem context independently of the
|
|
||||||
destination. A configuration destination is not a filesystem root or base
|
|
||||||
directory.
|
|
||||||
|
|
||||||
PromptKit can continue to enumerate an explicitly selected directory and attach
|
|
||||||
the same destination to its returned inputs. ConfigKit does not need a new
|
|
||||||
directory scanner, namespace registry, filename convention, or PromptKit schema.
|
|
||||||
The namespace comes from the application's assembly declaration, not from an
|
|
||||||
implicit interpretation of a directory basename.
|
|
||||||
|
|
||||||
### Representative Assembly
|
|
||||||
|
|
||||||
These are application choices, not ConfigKit-specific reserved paths:
|
|
||||||
|
|
||||||
| Selected inputs | Destination key segments |
|
|
||||||
| --- | --- |
|
|
||||||
| Application main document | Root |
|
|
||||||
| `weatherreporter/collection.yml` | `collections`, `weatherreporter` |
|
|
||||||
| YAML files under `weatherreporter/prompts` | `collections`, `weatherreporter`, `prompts` |
|
|
||||||
| YAML files under `homewatch/prompts` | `collections`, `homewatch`, `prompts` |
|
|
||||||
| YAML files under `provider/profiles` | `collections`, `provider`, `profiles` |
|
|
||||||
| YAML files under `provider/backends` | `collections`, `provider`, `backends` |
|
|
||||||
|
|
||||||
Peer collection contributions belong to a single strict underlay group when the
|
|
||||||
application wants conflict detection among them. Intentional operator overrides
|
|
||||||
use later groups or main. Destination selection must not establish precedence
|
|
||||||
or make source traversal order authorize replacement.
|
|
||||||
|
|
||||||
## Required Observable Behavior
|
|
||||||
|
|
||||||
### Composition And Precedence
|
|
||||||
|
|
||||||
Mounted and explicitly wrapped contributions must have equivalent application
|
|
||||||
values and conflict behavior at their effective destinations. Actual source
|
|
||||||
locations naturally differ and must remain accurate.
|
|
||||||
|
|
||||||
Retain the [strict composition](../config.md#strict-document-composition) and
|
|
||||||
[layering](../config.md#layered-loading) rules:
|
|
||||||
|
|
||||||
- Disjoint mapping entries at the same destination combine.
|
|
||||||
- Overlapping non-mapping values within a strict group conflict, including equal
|
|
||||||
scalars, nulls, and sequences. Different destinations do not collide merely
|
|
||||||
because the source documents share a local key.
|
|
||||||
- Later precedence layers recursively override mappings and replace other
|
|
||||||
values. A strict conflict cannot be repaired by an upper layer.
|
|
||||||
- Empty mappings do not clear lower definitions; ordinary arrays remain atomic.
|
|
||||||
In particular, `messages` does not acquire sequence-merging behavior.
|
|
||||||
- Overlapping destination paths and contributions at their ancestors follow
|
|
||||||
those same rules. No namespace-level uniqueness restriction is requested.
|
|
||||||
|
|
||||||
Changing only a prompt's `version` in a later layer changes that field while
|
|
||||||
retaining omitted lower-layer fields. ConfigKit must not treat a domain version
|
|
||||||
label as a request to replace an entire definition.
|
|
||||||
|
|
||||||
### Imports And Existing Structural Directives
|
|
||||||
|
|
||||||
An input with imports enabled must be able to use its existing source-root
|
|
||||||
`configkit.imports` directive while contributing application data at a nested
|
|
||||||
destination. The directive remains structural configuration, not an application
|
|
||||||
field below that destination.
|
|
||||||
|
|
||||||
For the minimum API, imports inherit their importing input's destination,
|
|
||||||
including transitive imports. Import paths continue to resolve relative to
|
|
||||||
their declaring source's filesystem context. A mounted document must not send
|
|
||||||
its imported application fields back to the configuration root accidentally.
|
|
||||||
|
|
||||||
Existing opt-in, containment, missing-input, cycle, and repeated-source behavior
|
|
||||||
must remain applicable. A different destination is not a request to bypass the
|
|
||||||
current rejection of a repeated physical source within a strict group.
|
|
||||||
|
|
||||||
Sibling `extends` must resolve against mappings at their effective destinations.
|
|
||||||
For example, two independently supplied profile fragments mounted beneath the
|
|
||||||
same collection's `profiles` mapping can establish `extends: brief` once they
|
|
||||||
compose. Inheritance remains ConfigKit-owned; PromptKit does not perform another
|
|
||||||
relationship-resolution pass.
|
|
||||||
|
|
||||||
### Protected Specification Paths
|
|
||||||
|
|
||||||
The distinction between source-local field names and effective application
|
|
||||||
paths is essential. PromptKit's main document will declare `version: 1` at the
|
|
||||||
configuration root. A separate collection metadata file can contain:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: "2.4.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
Mounted at `[collections, weatherreporter]`, that field is an ordinary collection
|
|
||||||
release label. It must not be rejected as an attempt to contribute the protected
|
|
||||||
root specification field. The same applies to a prompt's nested optional
|
|
||||||
version. Only the main-owned specification declaration uses the application's
|
|
||||||
configured [VersionRule](../config.md#application-schema-versions).
|
|
||||||
|
|
||||||
Conversely, placement must not permit an underlay or imported fragment to claim
|
|
||||||
or replace the actual protected specification path or an ancestor that would
|
|
||||||
replace it. The public contract must define protection in terms of effective
|
|
||||||
application paths, rather than reserving the spelling `version` in every source.
|
|
||||||
Protection must remain valid with imports, inheritance, overlays, and explicit
|
|
||||||
CLI overrides.
|
|
||||||
|
|
||||||
PromptKit needs one main-owned specification check with the existing lifecycle.
|
|
||||||
It does not need main mounted away from the root, integer-to-string conversion
|
|
||||||
for every nested `version`, or separate compatibility checks for every fragment.
|
|
||||||
|
|
||||||
### Typed Loading, Diagnostics, And Inspection
|
|
||||||
|
|
||||||
The destination must be visible consistently through the ordinary public
|
|
||||||
loading and inspection surface:
|
|
||||||
|
|
||||||
- Final typed configuration and application callbacks use effective destination
|
|
||||||
paths. Defaults, resolution, and validation retain the existing single-load
|
|
||||||
lifecycle; fragments are not independently validated as whole applications.
|
|
||||||
- Metadata and presence queries distinguish omission, explicit null, and other
|
|
||||||
explicit values at the mounted paths.
|
|
||||||
- Diagnostics identify the effective conflicting or invalid path and the real
|
|
||||||
contributing source locations. Consumers can determine which source and
|
|
||||||
placement produced a value without recreating that relationship themselves.
|
|
||||||
- Effective rendering, explanations, history, and configuration comparison agree
|
|
||||||
with the final configuration. Existing sensitivity and disclosure rules apply
|
|
||||||
to mounted fields, including application path-based secret classification.
|
|
||||||
- Unsupported destination forms and destination-related loading failures use
|
|
||||||
the normal structured error boundary. Exact new diagnostic codes, if needed,
|
|
||||||
are for ConfigKit to define.
|
|
||||||
|
|
||||||
PromptKit does not require a separate mount registry or new inspection facade.
|
|
||||||
Existing inspection APIs are sufficient if they expose accurate effective paths,
|
|
||||||
source attribution, and placement information. ConfigKit should document how
|
|
||||||
source-local locations relate to effective paths.
|
|
||||||
|
|
||||||
### Source-Relative Resources And Lifecycle
|
|
||||||
|
|
||||||
For a mounted field such as `content_file: ./summary.md`, obtaining its effective
|
|
||||||
origin and calling `Result.SourceContext` or `Resolution.SourceContext` must
|
|
||||||
still identify the declaring source's filesystem and base directory. The same
|
|
||||||
holds when that field survives an override or is inherited into another mapping.
|
|
||||||
Its configuration destination must not change the meaning of the relative file
|
|
||||||
reference.
|
|
||||||
|
|
||||||
Retain existing source ownership, load completion, result lifetime, containment,
|
|
||||||
and resource-access behavior. Destinations must participate in documented load
|
|
||||||
limits without creating a way to bypass them; this request does not prescribe
|
|
||||||
new limit values or their accounting machinery.
|
|
||||||
|
|
||||||
## Optional Follow-Ups, Not Prerequisites
|
|
||||||
|
|
||||||
**Import destinations in YAML.** A destination on an import directive could let
|
|
||||||
publishers describe assembly in configuration rather than Go. Proposal 3
|
|
||||||
recommends destinations relative to the importing document's destination, with
|
|
||||||
omission retaining that destination. This is useful, but per-input placement
|
|
||||||
through the public API is sufficient for initial PromptKit adoption. No new
|
|
||||||
directive spelling is selected by this request.
|
|
||||||
|
|
||||||
**Independent specification headers.** A collection entry document might later
|
|
||||||
declare its own supported specification version. That needs a distinct contract
|
|
||||||
for checking the header and determining its role in the composed configuration.
|
|
||||||
Mounting collection release metadata does not supply this capability. Keep it a
|
|
||||||
separate request if PromptKit decides it is necessary.
|
|
||||||
|
|
||||||
**Cross-collection inheritance.** Nested collection profiles are not immediate
|
|
||||||
siblings across collections. Current sibling inheritance remains sufficient for
|
|
||||||
the initial proposal, with authored definitions flattened where necessary.
|
|
||||||
Generic cross-path inheritance could be considered separately if consumer needs
|
|
||||||
justify revisiting the [deferred scope](deferred.md). Mounting must not silently
|
|
||||||
reinterpret `extends: provider/brief` as a cross-collection reference.
|
|
||||||
|
|
||||||
PromptKit also does not request collection installation, automatic directory
|
|
||||||
discovery, remote fetching, namespace ownership enforcement, dependency solving,
|
|
||||||
live reload, filename-derived identity, or arbitrary document transformations.
|
|
||||||
Its qualified resource IDs, optional prompt version semantics, collection
|
|
||||||
inventory, and execution APIs remain PromptKit responsibilities.
|
|
||||||
|
|
||||||
## Public Acceptance Scenarios
|
|
||||||
|
|
||||||
Evaluate the extension through supported public loading and inspection APIs:
|
|
||||||
|
|
||||||
| Scenario | Expected consumer-visible result |
|
|
||||||
| --- | --- |
|
|
||||||
| Destination omitted or empty | Existing root-placement behavior |
|
|
||||||
| Same fragment shape mounted under two distinct collection keys from independent sources | Two separate effective resources |
|
|
||||||
| Disjoint inputs at the same destination in one group | Mapping contributions combine |
|
|
||||||
| Equal or unequal overlapping scalar fields in one group | Structured composition conflict with both source locations |
|
|
||||||
| Same destination across deliberate precedence tiers | Existing recursive overlay behavior |
|
|
||||||
| Version-only prompt override | Label changes; omitted prompt body fields survive |
|
|
||||||
| Nested mounted import without a destination directive | Imported fields contribute at the inherited destination |
|
|
||||||
| Local profile inheritance across mounted peer fragments | Parent resolves at the effective sibling scope |
|
|
||||||
| Collection metadata containing `version` mounted below root | Ordinary nested field, independent of root VersionRule |
|
|
||||||
| Mounted/imported contribution actually targeting the protected version path | Rejected under version ownership rules |
|
|
||||||
| Mounted field using source-relative content, including inheritance | Original declaring resource context remains available |
|
|
||||||
| Mounted explicit null and omitted field | Distinct effective presence information |
|
|
||||||
| Mounted validation error or secret field | Effective paths, physical locations, and existing disclosure rules agree |
|
|
||||||
| File, native filesystem, byte, reader, or mapping input with equivalent content | Equivalent application values, subject to existing source contracts |
|
|
||||||
| Unsupported destination or failed load | Normal structured failure, no usable partial result |
|
|
||||||
|
|
||||||
The minimum usable outcome is a consumer that selects collection sources and
|
|
||||||
destinations, performs one ordinary layered load, and receives final typed
|
|
||||||
configuration with reliable diagnostics and inspection. It should need no
|
|
||||||
consumer-side source rewriting, generic merging, or provenance reconstruction.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Input destination review findings
|
|
||||||
|
|
||||||
Reviewed `d3d468a..945e111` against the approved
|
|
||||||
[input-destinations roadmap](input-destinations.md),
|
|
||||||
[implementation plan](implementation.md), and
|
|
||||||
[ADR 0011](../adr/0011-input-destinations.md).
|
|
||||||
|
|
||||||
The root-owned destination fields, placement before strict composition,
|
|
||||||
transitive import placement, effective-path version ownership, final lifecycle,
|
|
||||||
source records, resource contexts, retained history, and existing disclosure
|
|
||||||
mechanisms broadly match the selected architecture. The required correction
|
|
||||||
below concerns an early diagnostic path, not loading or composition semantics.
|
|
||||||
No additional unintended strengthening of contracts was identified.
|
|
||||||
|
|
||||||
## 1. Rebase known Mapping-source failure paths to the input destination (P2)
|
|
||||||
|
|
||||||
Status: complete. Known Mapping value-builder paths are now prefixed with the
|
|
||||||
normalized destination at acquisition, preserving semantic key/index segments.
|
|
||||||
Public regressions cover mounted main and underlay failures, escaped keys,
|
|
||||||
sequence indices, cyclic values, unchanged root paths, and source-constructor
|
|
||||||
errors retaining source coordinates. The mounted value cases failed before the
|
|
||||||
fix and pass afterward. Existing directive/syntax checks remain unchanged.
|
|
||||||
Focused destination/source/diagnostic/import tests, `./scripts/check.sh`, and
|
|
||||||
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed on
|
|
||||||
`go1.26.8 linux/amd64`.
|
|
||||||
|
|
||||||
Affected: `assembleBaseInputTracked` in
|
|
||||||
[load_input.go](../../internal/engine/load_input.go), lines 27–32 at the reviewed
|
|
||||||
commit; the Mapping failure originates in `readSource` in
|
|
||||||
[source.go](../../internal/engine/source.go), lines 206–208.
|
|
||||||
|
|
||||||
`Mapping` snapshots its input through the existing value builder. When a nested
|
|
||||||
value is unsupported, cyclic, or exceeds a value-builder limit, the retained
|
|
||||||
issue already identifies a semantic path inside that application mapping.
|
|
||||||
`readSource` turns it into a structured diagnostic. The early return from
|
|
||||||
`assembleBaseInputTracked` annotates the source record with its destination but
|
|
||||||
returns those diagnostic paths unchanged.
|
|
||||||
|
|
||||||
For example, select this input as either main or an underlay:
|
|
||||||
|
|
||||||
```go
|
|
||||||
configkit.Input{
|
|
||||||
Source: configkit.Mapping("bad mapping", map[string]any{
|
|
||||||
"item": map[string]any{"bad": func() {}},
|
|
||||||
}),
|
|
||||||
Destination: configkit.Keys("catalog", "entries"),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The load correctly fails with `source.invalid`, but `Diagnostic.Path` is
|
|
||||||
`/item/bad` rather than `/catalog/entries/item/bad`. The source record correctly
|
|
||||||
reports `/catalog/entries`, so the two inspection records use inconsistent
|
|
||||||
application coordinates. A consumer displaying or associating the failure with
|
|
||||||
the final application shape would point to the wrong configuration field.
|
|
||||||
|
|
||||||
This diverges from implementation decision 10 and Stage 6: existing paths that
|
|
||||||
genuinely identify application content must be translated to effective paths
|
|
||||||
exactly once. A Mapping value-builder issue has that information; it is not a
|
|
||||||
source-read or YAML syntax failure for which a logical child path is unavailable.
|
|
||||||
|
|
||||||
Required remediation:
|
|
||||||
|
|
||||||
- Prefix known application-relative Mapping value-builder diagnostic paths with
|
|
||||||
the normalized input destination at the acquisition boundary. Preserve key
|
|
||||||
escaping and sequence-index intent in the existing semantic Path model.
|
|
||||||
- Keep source IDs, original primary/related locations, codes, value-free
|
|
||||||
messages, source destinations, and nil-result behavior unchanged. Root
|
|
||||||
placement must retain its current paths.
|
|
||||||
- Do not blanket-prefix parse/read failures, source-constructor errors, or
|
|
||||||
structural import-directive diagnostics. Do not invent logical child paths
|
|
||||||
for failures that do not identify application content.
|
|
||||||
- Add bounded regression coverage through the public API for mounted main and
|
|
||||||
underlay Mapping failures, alongside unchanged root placement. Reuse existing
|
|
||||||
syntax/directive tests to protect their different coordinate convention.
|
|
||||||
- Run the focused destination/source/diagnostic checks and the everyday check.
|
|
||||||
Reconcile the completed plan's diagnostic evidence with this correction;
|
|
||||||
do not redefine the agreed effective-path behavior to accept the gap.
|
|
||||||
|
|
||||||
## Review validation
|
|
||||||
|
|
||||||
- `GOTOOLCHAIN=local ./scripts/check.sh` passed at `945e111` on
|
|
||||||
`go1.26.8 linux/amd64` (formatting, whitespace, tests, vet, and build).
|
|
||||||
- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed.
|
|
||||||
- `GOTOOLCHAIN=local GOPROXY=off go test -count=1 ./...` passed.
|
|
||||||
- An additional external-package probe in a disposable checkout outside the
|
|
||||||
repository failed for both main and underlay placement, observing `/item/bad`
|
|
||||||
instead of `/catalog/entries/item/bad` with `source.invalid`.
|
|
||||||
- No production fixes or regression tests were added to this repository during
|
|
||||||
the review. No performance measurements or new guarantees were inferred.
|
|
||||||
@@ -43,9 +43,9 @@ type CollectionProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadCollectionBundle demonstrates one layered collection load. Its main
|
// LoadCollectionBundle demonstrates one layered collection load. Its main
|
||||||
// document owns the application schema version; imported collection metadata,
|
// document owns the application schema version; the mounted entry declares
|
||||||
// prompt/profile peers, and an operator prompt-version override use ordinary
|
// relative destinations for collection metadata, prompt/profile peers, and
|
||||||
// destinations and domain fields.
|
// their nested imports. The operator version override remains a separate tier.
|
||||||
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||||
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +54,6 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
|||||||
bundle := configkit.NewFilesystem("collection bundle", files)
|
bundle := configkit.NewFilesystem("collection bundle", files)
|
||||||
collectionDestination := configkit.Keys("collections", "weatherreporter")
|
collectionDestination := configkit.Keys("collections", "weatherreporter")
|
||||||
promptDestination := collectionDestination.Key("prompts")
|
promptDestination := collectionDestination.Key("prompts")
|
||||||
profileDestination := collectionDestination.Key("profiles")
|
|
||||||
return configkit.LoadLayered(
|
return configkit.LoadLayered(
|
||||||
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
|
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
|
||||||
configkit.LayeredOptions[CollectionBundleConfig]{
|
configkit.LayeredOptions[CollectionBundleConfig]{
|
||||||
@@ -69,13 +68,9 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
|||||||
Validate: validateCollectionBundle,
|
Validate: validateCollectionBundle,
|
||||||
},
|
},
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{
|
{Inputs: []configkit.Input{{
|
||||||
{Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination},
|
Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination,
|
||||||
{Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination},
|
}}},
|
||||||
{Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination},
|
|
||||||
{Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination},
|
|
||||||
{Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination},
|
|
||||||
}},
|
|
||||||
{Inputs: []configkit.Input{{
|
{Inputs: []configkit.Input{{
|
||||||
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
||||||
}}},
|
}}},
|
||||||
|
|||||||
@@ -69,22 +69,43 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
|||||||
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
||||||
}
|
}
|
||||||
var metadataSource configkit.SourceInfo
|
var metadataSource configkit.SourceInfo
|
||||||
|
var collectionEntrySource configkit.SourceInfo
|
||||||
|
var versionSource configkit.SourceInfo
|
||||||
var parentPromptSource configkit.SourceInfo
|
var parentPromptSource configkit.SourceInfo
|
||||||
|
var promptIndexSource configkit.SourceInfo
|
||||||
|
var profileIndexSource configkit.SourceInfo
|
||||||
var promptVersionSource configkit.SourceInfo
|
var promptVersionSource configkit.SourceInfo
|
||||||
for _, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
switch source.Path {
|
switch source.Path {
|
||||||
|
case "collections/weather/collection.yml":
|
||||||
|
collectionEntrySource = source
|
||||||
case "collections/weather/metadata.yml":
|
case "collections/weather/metadata.yml":
|
||||||
metadataSource = source
|
metadataSource = source
|
||||||
|
case "collections/weather/version.yml":
|
||||||
|
versionSource = source
|
||||||
|
case "collections/weather/prompts/index.yml":
|
||||||
|
promptIndexSource = source
|
||||||
case "collections/weather/prompts/brief.yml":
|
case "collections/weather/prompts/brief.yml":
|
||||||
parentPromptSource = source
|
parentPromptSource = source
|
||||||
|
case "collections/weather/profiles/index.yml":
|
||||||
|
profileIndexSource = source
|
||||||
case "operators/weather-prompt-version.yml":
|
case "operators/weather-prompt-version.yml":
|
||||||
promptVersionSource = source
|
promptVersionSource = source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collectionVersionLocation := collectionVersion.Origin.Locations[0]
|
collectionVersionLocation := collectionVersion.Origin.Locations[0]
|
||||||
if metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID ||
|
if collectionEntrySource.ID != "source-2" || collectionEntrySource.Destination.String() != "/collections/weatherreporter" ||
|
||||||
metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 {
|
metadataSource.ID == "" || metadataSource.Destination.String() != "/collections/weatherreporter" ||
|
||||||
t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation)
|
metadataSource.ImportedFrom == nil || metadataSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||||
|
versionSource.ID == "" || collectionVersionLocation.SourceID != versionSource.ID || versionSource.Destination.String() != "/collections/weatherreporter" ||
|
||||||
|
versionSource.ImportedFrom == nil || versionSource.ImportedFrom.SourceID != metadataSource.ID || collectionVersionLocation.Line != 1 {
|
||||||
|
t.Fatalf("nested collection version provenance = entry %#v, metadata %#v, version %#v, location %#v", collectionEntrySource, metadataSource, versionSource, collectionVersionLocation)
|
||||||
|
}
|
||||||
|
if promptIndexSource.ID == "" || promptIndexSource.Destination.String() != "/collections/weatherreporter/prompts" ||
|
||||||
|
promptIndexSource.ImportedFrom == nil || promptIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||||
|
profileIndexSource.ID == "" || profileIndexSource.Destination.String() != "/collections/weatherreporter/profiles" ||
|
||||||
|
profileIndexSource.ImportedFrom == nil || profileIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID {
|
||||||
|
t.Fatalf("nested prompt/profile placement = prompt %#v profile %#v", promptIndexSource, profileIndexSource)
|
||||||
}
|
}
|
||||||
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
||||||
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle
|
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
||||||
var bundledFiles embed.FS
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||||
@@ -18,6 +18,30 @@ type BundledConfig struct {
|
|||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptBundleConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Prompts map[string]BundlePrompt `yaml:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BundlePrompt struct {
|
||||||
|
Summary string `yaml:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose
|
||||||
|
// destinations are declared relative to the entry document.
|
||||||
|
func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return configkit.Load[PromptBundleConfig](
|
||||||
|
configkit.NewFilesystem("prompt bundle", files).File("config.yml"),
|
||||||
|
configkit.Options[PromptBundleConfig]{Imports: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// LoadBundled demonstrates a native filesystem source without layered options.
|
// LoadBundled demonstrates a native filesystem source without layered options.
|
||||||
func LoadBundled() (*configkit.Result[BundledConfig], error) {
|
func LoadBundled() (*configkit.Result[BundledConfig], error) {
|
||||||
files, err := fs.Sub(bundledFiles, "testdata/native-source")
|
files, err := fs.Sub(bundledFiles, "testdata/native-source")
|
||||||
|
|||||||
@@ -20,6 +20,34 @@ func Example_nativeFilesystem() {
|
|||||||
// Output: embedded false
|
// Output: embedded false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Example_rootRelativeImports() {
|
||||||
|
result, err := consumers.LoadPromptBundle()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source := result.Sources()[2]
|
||||||
|
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
|
||||||
|
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) {
|
||||||
|
result, err := consumers.LoadPromptBundle()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." {
|
||||||
|
t.Fatalf("root import result = %#v", result.Config)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() ||
|
||||||
|
sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() ||
|
||||||
|
sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" ||
|
||||||
|
sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID {
|
||||||
|
t.Fatalf("root import sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Example_layeredCatalog() {
|
func Example_layeredCatalog() {
|
||||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,6 +60,58 @@ func Example_layeredCatalog() {
|
|||||||
// Output: production false 0 bundle example Welcome from the bundle. <nil>
|
// Output: production false 0 bundle example Welcome from the bundle. <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Example_sourceVersionAdoption() {
|
||||||
|
result, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checks, redacted := 0, true
|
||||||
|
for _, source := range result.Sources() {
|
||||||
|
if check := source.VersionCheck; check != nil {
|
||||||
|
checks++
|
||||||
|
redacted = redacted && check.Redacted && check.Version.Text == ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
required, requiredErr := consumers.LoadVersionedService(configkit.SourceVersionModeRequired)
|
||||||
|
fmt.Println(result.Config.Service.Version, result.Config.Service.Metadata.Release,
|
||||||
|
checks, redacted, required != nil && requiredErr == nil)
|
||||||
|
// Output: service-v2 application-2026.10 2 true true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
|
||||||
|
for _, mode := range []configkit.SourceVersionMode{
|
||||||
|
configkit.SourceVersionModeIfPresent,
|
||||||
|
configkit.SourceVersionModeRequired,
|
||||||
|
} {
|
||||||
|
result, err := consumers.LoadVersionedService(mode)
|
||||||
|
if err != nil || result == nil || result.Config.Service.Version != "service-v2" ||
|
||||||
|
result.Config.Service.Name != "example service" || result.Config.Service.Endpoint != "https://service.example.invalid" ||
|
||||||
|
result.Config.Service.Metadata.Release != "application-2026.10" {
|
||||||
|
t.Fatalf("versioned service mode %d = %#v, %v", mode, result, err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 {
|
||||||
|
t.Fatalf("versioned service sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for _, source := range sources {
|
||||||
|
check := source.VersionCheck
|
||||||
|
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
||||||
|
!check.Redacted || check.Version.Text != "" {
|
||||||
|
t.Fatalf("safe source version evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent,
|
||||||
|
configkit.Override{Path: configkit.Keys("service", "version"), Value: "cli-v3", Flag: "--service-version"})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||||
|
loadError.Diagnostics()[0].Code != configkit.CodeOverrideVersion ||
|
||||||
|
loadError.Diagnostics()[0].Path.String() != "/service/version" {
|
||||||
|
t.Fatalf("mounted main version override error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
||||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
53
examples/consumers/prompt_catalog.go
Normal file
53
examples/consumers/prompt_catalog.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
82
examples/consumers/prompt_catalog_test.go
Normal file
82
examples/consumers/prompt_catalog_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
examples/consumers/source_version.go
Normal file
64
examples/consumers/source_version.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionedServiceConfig demonstrates a mounted main input whose source-local
|
||||||
|
// schema version is retained at the effective service path.
|
||||||
|
type VersionedServiceConfig struct {
|
||||||
|
Service struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Metadata struct {
|
||||||
|
Release string `yaml:"release"`
|
||||||
|
} `yaml:"metadata"`
|
||||||
|
} `yaml:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadVersionedService checks the main and underlay declarations with one
|
||||||
|
// application policy. The local rule path is "version" for each source; the
|
||||||
|
// main input's destination places its protected value at /service/version.
|
||||||
|
// Optional CLI overrides are passed through to ConfigKit for normal protection.
|
||||||
|
func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configkit.Override) (*configkit.Result[VersionedServiceConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/source-version")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
main, err := fs.ReadFile(files, "main.yml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
underlay, err := fs.ReadFile(files, "underlay.yml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[VersionedServiceConfig]{
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
if !version.Present || version.Null || version.Text == "" {
|
||||||
|
return errors.New("a nonempty source version is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Secret: func(path configkit.Path) bool {
|
||||||
|
return path.String() == "/service/version"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("service main", main), Destination: configkit.Keys("service"),
|
||||||
|
}, configkit.LayeredOptions[VersionedServiceConfig]{
|
||||||
|
Definition: definition,
|
||||||
|
SourceVersionMode: mode,
|
||||||
|
Overrides: overrides,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("service defaults", underlay), Destination: configkit.Keys("service"),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: metadata.yml
|
- path: metadata.yml
|
||||||
|
destination: []
|
||||||
|
- path: prompts/index.yml
|
||||||
|
destination: [prompts]
|
||||||
|
- path: profiles/index.yml
|
||||||
|
destination: [profiles]
|
||||||
name: Weather Reporter
|
name: Weather Reporter
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
version: "2.4.0"
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: version.yml
|
||||||
description: Imported collection metadata.
|
description: Imported collection metadata.
|
||||||
|
|||||||
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: compact.yml
|
||||||
|
- path: weather.yml
|
||||||
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: brief.yml
|
||||||
|
- path: custom.yml
|
||||||
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
version: "2.4.0"
|
||||||
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: metadata.yml
|
||||||
|
- path: prompts/brief.yml
|
||||||
|
destination: [prompts]
|
||||||
|
name: Root import example
|
||||||
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: "1.0"
|
||||||
|
description: Metadata imported into the root document.
|
||||||
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
brief:
|
||||||
|
summary: A root-loaded prompt.
|
||||||
12
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
12
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal 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.]
|
||||||
4
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
prompts:
|
||||||
|
- id: invalid
|
||||||
|
name: Invalid prompt
|
||||||
|
messages: []
|
||||||
1
examples/consumers/testdata/prompt-catalog/prompts/curated-id.yml
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/curated-id.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
id: curated
|
||||||
6
examples/consumers/testdata/prompt-catalog/prompts/curated.yml
vendored
Normal file
6
examples/consumers/testdata/prompt-catalog/prompts/curated.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: curated-id.yml
|
||||||
|
name: Curated summary
|
||||||
|
messages:
|
||||||
|
- Summarize the supplied content.
|
||||||
4
examples/consumers/testdata/prompt-catalog/prompts/generated-weather.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/prompts/generated-weather.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: weather
|
||||||
|
name: Weather forecast
|
||||||
|
messages:
|
||||||
|
- Describe today's forecast.
|
||||||
4
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: welcome
|
||||||
|
name: Welcome
|
||||||
|
messages:
|
||||||
|
- Welcome the user.
|
||||||
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
version: service-v2
|
||||||
|
name: example service
|
||||||
|
metadata:
|
||||||
|
release: application-2026.10
|
||||||
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: catalog-v1
|
||||||
|
endpoint: https://service.example.invalid
|
||||||
@@ -155,6 +155,139 @@ func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
write := func(relative, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
filename := filepath.Join(directory, filepath.FromSlash(relative))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
mainPath := write("main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: metadata.yml
|
||||||
|
destination: [metadata]
|
||||||
|
- glob: prompts/*.yml
|
||||||
|
destination: [prompts]
|
||||||
|
- path: entries/main.yml
|
||||||
|
destination: [profiles]
|
||||||
|
- path: sibling.yml
|
||||||
|
destination: [sibling]
|
||||||
|
root: main
|
||||||
|
`)
|
||||||
|
write("metadata.yml", "name: imported\n")
|
||||||
|
write("prompts/a.yml", "a: {name: alpha}\n")
|
||||||
|
write("prompts/b.yml", "b: {name: beta}\n")
|
||||||
|
write("entries/main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: nested/details.yml
|
||||||
|
destination: [details]
|
||||||
|
name: profile
|
||||||
|
`)
|
||||||
|
write("entries/nested/details.yml", "summary: nested\n")
|
||||||
|
write("sibling.yml", "name: separate\n")
|
||||||
|
|
||||||
|
root, err := configkit.Load(
|
||||||
|
configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||||
|
t.Fatalf("ordinary root Load: %v, diagnostics %#v", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("ordinary root Load: %v", err)
|
||||||
|
}
|
||||||
|
mountedDestination := configkit.Keys("collections", "weatherreporter")
|
||||||
|
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: mountedDestination,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mounted layered Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
result *configkit.Result[map[string]any]
|
||||||
|
prefix []string
|
||||||
|
}{
|
||||||
|
{name: "ordinary root", result: root},
|
||||||
|
{name: "mounted entry", result: mounted, prefix: []string{"collections", "weatherreporter"}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
at := func(keys ...string) []string {
|
||||||
|
return append(append([]string(nil), test.prefix...), keys...)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("root")...); got != "main" {
|
||||||
|
t.Fatalf("root value = %#v", got)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("metadata", "name")...); got != "imported" {
|
||||||
|
t.Fatalf("metadata value = %#v", got)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "a", "name")...); got != "alpha" {
|
||||||
|
t.Fatalf("first glob value = %#v", got)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "b", "name")...); got != "beta" {
|
||||||
|
t.Fatalf("second glob value = %#v", got)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "name")...); got != "profile" {
|
||||||
|
t.Fatalf("entry value = %#v", got)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "details", "summary")...); got != "nested" {
|
||||||
|
t.Fatalf("nested import value = %#v", got)
|
||||||
|
}
|
||||||
|
if got := facadeDestinationValue(t, test.result.Config, at("sibling", "name")...); got != "separate" {
|
||||||
|
t.Fatalf("subsequent sibling value = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPaths := []string{
|
||||||
|
"main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml",
|
||||||
|
}
|
||||||
|
wantPhysicalPaths := []string{
|
||||||
|
mainPath,
|
||||||
|
filepath.Join(directory, "metadata.yml"),
|
||||||
|
filepath.Join(directory, "prompts", "a.yml"),
|
||||||
|
filepath.Join(directory, "prompts", "b.yml"),
|
||||||
|
filepath.Join(directory, "entries", "main.yml"),
|
||||||
|
filepath.Join(directory, "entries", "nested", "details.yml"),
|
||||||
|
filepath.Join(directory, "sibling.yml"),
|
||||||
|
}
|
||||||
|
wantImportParents := []string{"", "source-1", "source-1", "source-1", "source-1", "source-5", "source-1"}
|
||||||
|
wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)}
|
||||||
|
for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} {
|
||||||
|
destination := append([]string(nil), test.prefix...)
|
||||||
|
for _, segment := range suffix {
|
||||||
|
destination = append(destination, segment)
|
||||||
|
}
|
||||||
|
wantDestinations = append(wantDestinations, configkit.Keys(destination...))
|
||||||
|
}
|
||||||
|
sources := test.result.Sources()
|
||||||
|
if len(sources) != len(wantPaths) {
|
||||||
|
t.Fatalf("sources = %#v, want %d", sources, len(wantPaths))
|
||||||
|
}
|
||||||
|
for index, source := range sources {
|
||||||
|
if filepath.Base(source.Path) != wantPaths[index] || source.Path != wantPhysicalPaths[index] || source.Destination.String() != wantDestinations[index].String() {
|
||||||
|
t.Errorf("source %d path/destination = (%q, %q), want physical path %q and destination %q", index, source.Path, source.Destination.String(), wantPhysicalPaths[index], wantDestinations[index].String())
|
||||||
|
}
|
||||||
|
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFile {
|
||||||
|
t.Errorf("source %d identity/kind = (%q, %q)", index, source.ID, source.Kind)
|
||||||
|
}
|
||||||
|
if wantImportParents[index] == "" {
|
||||||
|
if source.ImportedFrom != nil {
|
||||||
|
t.Errorf("entry source unexpectedly has an import location: %#v", source.ImportedFrom)
|
||||||
|
}
|
||||||
|
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != wantImportParents[index] || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||||
|
t.Errorf("source %d import location = %#v, want declaration in %s", index, source.ImportedFrom, wantImportParents[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
||||||
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
||||||
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
||||||
@@ -357,6 +490,135 @@ func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
write := func(name, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
filename := filepath.Join(directory, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("imported parse failure retains effective source destination", func(t *testing.T) {
|
||||||
|
mainPath := write("parse/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||||
|
childPath := write("parse/child.yml", "count: [unterminated\n")
|
||||||
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||||
|
t.Fatalf("imported parse diagnostic = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||||
|
t.Fatalf("imported parse failure sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("directive failure stays source local", func(t *testing.T) {
|
||||||
|
mainPath := write("directive/main.yml", "configkit:\n imports:\n - path: parent.yml\n destination: [tenant]\n")
|
||||||
|
parentPath := write("directive/parent.yml", "configkit:\n imports:\n - path: details.yml\n destination: [3]\n")
|
||||||
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid || !reflect.DeepEqual(diagnostics[0].Path, wantPath) || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||||
|
t.Fatalf("import directive diagnostic = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != parentPath {
|
||||||
|
t.Fatalf("directive failure sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("composition failure reports effective path and all sources", func(t *testing.T) {
|
||||||
|
mainPath := write("compose/main.yml", "configkit:\n imports:\n - path: left.yml\n destination: [tenant]\n - path: right.yml\n destination: [tenant]\n")
|
||||||
|
write("compose/left.yml", "value: same\n")
|
||||||
|
write("compose/right.yml", "value: same\n")
|
||||||
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeComposeConflict || diagnostics[0].Path.String() != "/mount/tenant/value" {
|
||||||
|
t.Fatalf("composition diagnostic = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[2].Destination.String() != "/mount/tenant" {
|
||||||
|
t.Fatalf("composition failure sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("version failure reports effective path and imported source", func(t *testing.T) {
|
||||||
|
mainPath := write("version/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||||
|
childPath := write("version/child.yml", "version: forbidden\n")
|
||||||
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
|
}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("mount", "tenant", "version"),
|
||||||
|
Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportVersion || diagnostics[0].Path.String() != "/mount/tenant/version" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||||
|
t.Fatalf("version diagnostic = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||||
|
t.Fatalf("version failure sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("typed validation reports effective path and authored location", func(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Mount struct {
|
||||||
|
Tenant struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
} `yaml:"tenant"`
|
||||||
|
} `yaml:"mount"`
|
||||||
|
}
|
||||||
|
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||||
|
childPath := write("typed/child.yml", "count: invalid\n")
|
||||||
|
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||||
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeDecodeType || diagnostics[0].Path.String() != "/mount/tenant/count" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || diagnostics[0].Primary.Line != 1 {
|
||||||
|
t.Fatalf("typed validation diagnostic = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||||
|
t.Fatalf("typed validation sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package configkit_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
@@ -219,3 +221,77 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
|
|||||||
t.Fatal("CLI source returned a context")
|
t.Fatal("CLI source returned a context")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
|
||||||
|
borrowed := &observedResourceFS{files: fstest.MapFS{
|
||||||
|
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
|
||||||
|
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
|
||||||
|
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
|
||||||
|
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
||||||
|
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
|
||||||
|
}}
|
||||||
|
namespace := configkit.NewFilesystem("bundle", borrowed)
|
||||||
|
input := configkit.Input{
|
||||||
|
Source: namespace.File("bundle/config/main.yml"), Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
||||||
|
}
|
||||||
|
load := func() *configkit.Result[map[string]any] {
|
||||||
|
t.Helper()
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load relative native imports: %v", err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result := load()
|
||||||
|
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
|
||||||
|
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != len(wantPaths) {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for index, source := range sources {
|
||||||
|
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
|
||||||
|
t.Errorf("source %d = %#v", index, source)
|
||||||
|
}
|
||||||
|
if index == 0 {
|
||||||
|
if source.ImportedFrom != nil {
|
||||||
|
t.Errorf("entry source has import location %#v", source.ImportedFrom)
|
||||||
|
}
|
||||||
|
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||||
|
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
field, effectivePath, wantBase, wantResource, wantContents string
|
||||||
|
}{
|
||||||
|
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
|
||||||
|
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
|
||||||
|
} {
|
||||||
|
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
|
||||||
|
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
|
||||||
|
if err != nil || string(contents) != test.wantContents {
|
||||||
|
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sources[1].Destination = configkit.Keys("caller", "mutation")
|
||||||
|
for query := 0; query < 2; query++ {
|
||||||
|
fresh := result.Sources()
|
||||||
|
if fresh[1].Destination.String() != "/logical" {
|
||||||
|
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
second := load()
|
||||||
|
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
|
||||||
|
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
669
facade_sequence_test.go
Normal file
669
facade_sequence_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
879
facade_source_version_mode_test.go
Normal file
879
facade_source_version_mode_test.go
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, grouped := range []bool{false, true} {
|
||||||
|
for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} {
|
||||||
|
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
|
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||||
|
}
|
||||||
|
first := "records: [{id: first, version: first-version}]\n"
|
||||||
|
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
var err error
|
||||||
|
if grouped {
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
{Source: configkit.Bytes("first", []byte(first))}, {Source: configkit.Bytes("child", []byte(child))},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)},
|
||||||
|
"child.yml": {Data: []byte(child)},
|
||||||
|
})
|
||||||
|
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, Imports: true, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
check := sources[len(sources)-1].VersionCheck
|
||||||
|
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||||
|
t.Fatalf("source-local evidence changed: %#v", check)
|
||||||
|
}
|
||||||
|
if sensitivePath != "" {
|
||||||
|
if !check.Redacted || check.Version.Text != "" {
|
||||||
|
t.Fatal("shifted version evidence was disclosed")
|
||||||
|
}
|
||||||
|
} else if check.Redacted || check.Version.Text != "PRIVATE-VERSION-CANARY" {
|
||||||
|
t.Fatal("unclassified version evidence was lost")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
parsed, err := configkit.ParsePath("/records/0/version")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, grouped := range []bool{false, true} {
|
||||||
|
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
||||||
|
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
|
}
|
||||||
|
first := "records: [{id: first, version: first}]\n"
|
||||||
|
child := "records: [{id: child, version: child}]\n"
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
if grouped {
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(child))}}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)}, "child.yml": {Data: []byte(child)}})
|
||||||
|
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Imports: true})
|
||||||
|
}
|
||||||
|
if err != nil || result == nil {
|
||||||
|
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
||||||
|
}
|
||||||
|
want := 2
|
||||||
|
if grouped {
|
||||||
|
want = 1
|
||||||
|
}
|
||||||
|
if len(result.Config["records"].([]any)) != want {
|
||||||
|
t.Fatal("version path changed composition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeItemMainKeepsDefaultVersionOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
for _, protected := range []bool{true, false} {
|
||||||
|
body, helper := "id: main\n", "version: imported-version\n"
|
||||||
|
if !protected {
|
||||||
|
body += "version: main-version\n"
|
||||||
|
helper = "other: {version: permitted}\n"
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("item-main", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte(body + "configkit: {imports: [{path: helper.yml}]}\n")}, "helper.yml": {Data: []byte(helper)},
|
||||||
|
})
|
||||||
|
checks := 0
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("records").Index(0).Key("version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checks++
|
||||||
|
if version.Text != "main-version" {
|
||||||
|
t.Error("helper supplied main version")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: filesystem.File("main.yml"), Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||||
|
} else {
|
||||||
|
_, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem})
|
||||||
|
}
|
||||||
|
if protected {
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportVersion || loadError.Diagnostics()[0].Path.String() != "/records/0/version" || checks != 0 {
|
||||||
|
t.Fatalf("layered=%t ownership error = %v, checks=%d", layered, err, checks)
|
||||||
|
}
|
||||||
|
} else if err != nil || checks != 1 {
|
||||||
|
t.Fatalf("unrelated helper version rejected: %v, checks=%d", err, checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceVersionModeReader struct {
|
||||||
|
*strings.Reader
|
||||||
|
reads int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type header struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
type secretHeader struct {
|
||||||
|
Version string `yaml:"version" configkit:"secret"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Meta map[string]header `yaml:"meta"`
|
||||||
|
Data struct {
|
||||||
|
Meta map[string]secretHeader `yaml:"meta"`
|
||||||
|
} `yaml:"data"`
|
||||||
|
}
|
||||||
|
path, err := configkit.ParsePath("/meta/0/version")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, reverse := range []bool{false, true} {
|
||||||
|
groups := []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("mapping", []byte("meta: {\"0\": {version: PRIVATE-HEADER-CANARY}}\n")),
|
||||||
|
Destination: configkit.Keys("data"),
|
||||||
|
}}},
|
||||||
|
{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("sequence", []byte("meta: [{version: another-header}]\n")),
|
||||||
|
Destination: configkit.Keys("data"),
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
if reverse {
|
||||||
|
groups[0], groups[1] = groups[1], groups[0]
|
||||||
|
}
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("meta: {\"0\": {version: main}}\ndata: {meta: null}\n")),
|
||||||
|
}, configkit.LayeredOptions[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{Version: &configkit.VersionRule{
|
||||||
|
Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}},
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: groups,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Config.Meta["0"].Version != "main" || loaded.Config.Data.Meta != nil {
|
||||||
|
t.Fatalf("reverse=%v: final payload changed: %#v", reverse, loaded.Config)
|
||||||
|
}
|
||||||
|
foundMapping := false
|
||||||
|
for _, source := range loaded.Sources() {
|
||||||
|
if source.Label != "mapping" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
foundMapping = true
|
||||||
|
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||||
|
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMapping {
|
||||||
|
t.Fatal("missing mapping source record")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionModesPreserveImportOptIn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||||
|
for _, underlay := range []bool{false, true} {
|
||||||
|
bad := configkit.Bytes("disabled-imports", []byte("version: accepted\nconfigkit: {imports: [{path: missing.yml}]}\nvalue: visible\n"))
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}}
|
||||||
|
var err error
|
||||||
|
var resultPresent bool
|
||||||
|
wantSource := "source-1"
|
||||||
|
if underlay {
|
||||||
|
loaded, loadErr := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: main\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: mode,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: bad}}}},
|
||||||
|
})
|
||||||
|
err, resultPresent, wantSource = loadErr, loaded != nil, "source-2"
|
||||||
|
} else {
|
||||||
|
loaded, loadErr := configkit.Load(bad, configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: mode})
|
||||||
|
err, resultPresent = loadErr, loaded != nil
|
||||||
|
}
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if resultPresent || !errors.As(err, &failure) {
|
||||||
|
t.Errorf("mode=%v underlay=%v: import opt-in did not fail safely", mode, underlay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diagnostics := failure.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportDisabled || diagnostics[0].Path.String() != "/configkit" ||
|
||||||
|
diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != wantSource {
|
||||||
|
t.Errorf("mode=%v underlay=%v: diagnostics=%#v", mode, underlay, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, main, underlay string
|
||||||
|
path configkit.Path
|
||||||
|
}{
|
||||||
|
{"missing member", "meta: {version: main}\n", "{}\n", configkit.Keys("meta", "version")},
|
||||||
|
{"scalar obstruction", "meta: {version: main}\n", "meta: false\n", configkit.Keys("meta", "version")},
|
||||||
|
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
||||||
|
} {
|
||||||
|
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||||
|
var callbackEvidence *configkit.SourceVersionCheck
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
SourceVersionMode: mode,
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
source, found := resolution.Source("source-2")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing callback source")
|
||||||
|
}
|
||||||
|
callbackEvidence = source.VersionCheck
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
var evidence *configkit.SourceVersionCheck
|
||||||
|
if mode == configkit.SourceVersionModeRequired {
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if loaded != nil || !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("%s: required omission did not fail", test.name)
|
||||||
|
}
|
||||||
|
diagnostics := failure.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
||||||
|
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
||||||
|
}
|
||||||
|
evidence = failure.Sources()[1].VersionCheck
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", test.name, err)
|
||||||
|
}
|
||||||
|
evidence = loaded.Sources()[1].VersionCheck
|
||||||
|
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||||
|
t.Errorf("%s: callback path is incomplete", test.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if evidence == nil || evidence.Version.Present || evidence.Path.String() != test.path.String() ||
|
||||||
|
evidence.Location == nil || evidence.Location.SourceID != "source-2" || evidence.Location.Line != 1 {
|
||||||
|
t.Errorf("%s mode=%v: absence evidence=%#v", test.name, mode, evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mode configkit.SourceVersionMode
|
||||||
|
control string
|
||||||
|
wantCode configkit.Code
|
||||||
|
}{
|
||||||
|
{"optional omission", configkit.SourceVersionModeIfPresent, "base", ""},
|
||||||
|
{"required omission", configkit.SourceVersionModeRequired, "base", configkit.CodeVersionError},
|
||||||
|
{"invalid control", configkit.SourceVersionModeIfPresent, "true", configkit.CodeInheritanceInvalid},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
main := configkit.Bytes("main", []byte("{}\n"))
|
||||||
|
if test.mode == configkit.SourceVersionModeRequired {
|
||||||
|
main = configkit.NoSource()
|
||||||
|
}
|
||||||
|
checks := 0
|
||||||
|
path := configkit.Keys("entries", "child", "extends", "version")
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: main, Destination: configkit.Keys("main")}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
SourceVersionMode: test.mode,
|
||||||
|
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checks++
|
||||||
|
if version.Present {
|
||||||
|
t.Error("checker received a nonexistent declaration")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("underlay", []byte("entries:\n base: {value: good}\n child: {extends: "+test.control+"}\n")), Destination: configkit.Keys("under"),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
if checks != 1 {
|
||||||
|
t.Errorf("checker ran %d times, want absent main only", checks)
|
||||||
|
}
|
||||||
|
if test.wantCode != "" {
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if loaded != nil || !errors.As(err, &failure) {
|
||||||
|
t.Fatal("invalid input did not fail safely")
|
||||||
|
}
|
||||||
|
if diagnostics := failure.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode {
|
||||||
|
t.Fatalf("diagnostics=%#v", diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
child := loaded.Config["under"].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||||
|
if child["value"] != "good" {
|
||||||
|
t.Fatal("valid sibling inheritance was not applied")
|
||||||
|
}
|
||||||
|
check := loaded.Sources()[1].VersionCheck
|
||||||
|
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
||||||
|
t.Fatal("optional omission evidence was not retained")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
|
||||||
|
reader.reads++
|
||||||
|
return reader.Reader.Read(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionModesAcrossOrdinaryAndLayeredLoads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var checked []configkit.Version
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("meta", "version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checked = append(checked, version)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
ordinary, err := configkit.Load(configkit.Bytes("ordinary", []byte("meta: {version: ordinary-v}\n")), configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
if err != nil || ordinary == nil {
|
||||||
|
t.Fatalf("ordinary enabled load = %#v, %v", ordinary, err)
|
||||||
|
}
|
||||||
|
if got := ordinary.Config["meta"].(map[string]any)["version"]; got != "ordinary-v" {
|
||||||
|
t.Fatalf("main declaration was not retained: %#v", ordinary.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("meta: {version: main-v}\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
layered, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Mapping("underlay", map[string]any{
|
||||||
|
"meta": map[string]any{"version": "underlay-v"}, "fallback": "present",
|
||||||
|
}),
|
||||||
|
Destination: configkit.Keys("defaults"),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
if err != nil || layered == nil {
|
||||||
|
t.Fatalf("layered enabled load = %#v, %v", layered, err)
|
||||||
|
}
|
||||||
|
defaults, ok := layered.Config["defaults"].(map[string]any)
|
||||||
|
if !ok || defaults["fallback"] != "present" {
|
||||||
|
t.Fatalf("layered underlay payload = %#v", layered.Config["defaults"])
|
||||||
|
}
|
||||||
|
if meta, ok := defaults["meta"].(map[string]any); !ok || len(meta) != 0 {
|
||||||
|
t.Fatalf("accepted underlay declaration was not consumed: %#v", defaults["meta"])
|
||||||
|
}
|
||||||
|
if len(checked) != 3 {
|
||||||
|
t.Fatalf("checker calls across separate mode uses = %#v", checked)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"ordinary-v", "main-v", "underlay-v"} {
|
||||||
|
found := false
|
||||||
|
for _, version := range checked {
|
||||||
|
found = found || version.Present && version.Text == want
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("checked versions %#v do not include %q", checked, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionModeZeroPreservesDefaultBehavior(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
checks := 0
|
||||||
|
result, err := configkit.Load(configkit.Bytes("default", []byte("version: v1\n")), configkit.Options[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checks++
|
||||||
|
if version != (configkit.Version{Present: true, Text: "v1"}) {
|
||||||
|
t.Errorf("default version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.Config["version"] != "v1" || checks != 1 {
|
||||||
|
t.Fatalf("zero-mode load = %#v, %v; checks = %d", result, err, checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionModeValidationPrecedesSourceReads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mode configkit.SourceVersionMode
|
||||||
|
def configkit.Definition[map[string]any]
|
||||||
|
layered bool
|
||||||
|
}{
|
||||||
|
{name: "unknown ordinary mode", mode: configkit.SourceVersionMode(255), def: validDefinition},
|
||||||
|
{name: "ordinary mode requires checker", mode: configkit.SourceVersionModeIfPresent},
|
||||||
|
{name: "unknown layered mode", mode: configkit.SourceVersionMode(255), def: validDefinition, layered: true},
|
||||||
|
{name: "layered mode requires checker", mode: configkit.SourceVersionModeRequired, layered: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
reader := &sourceVersionModeReader{Reader: strings.NewReader("version: v1\n")}
|
||||||
|
source := configkit.Reader("version.yml", reader)
|
||||||
|
var err error
|
||||||
|
if test.layered {
|
||||||
|
_, err = configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: test.def, SourceVersionMode: test.mode,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, err = configkit.Load(source, configkit.Options[map[string]any]{
|
||||||
|
Definition: test.def, SourceVersionMode: test.mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||||
|
t.Fatalf("invalid mode error = %#v", err)
|
||||||
|
}
|
||||||
|
if reader.reads != 0 {
|
||||||
|
t.Fatalf("invalid options read source %d times", reader.reads)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
checks := 0
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checks++
|
||||||
|
if version.Present {
|
||||||
|
t.Errorf("missing declaration reached checker as %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
source := configkit.Bytes("reused", []byte("value: retained\n"))
|
||||||
|
ifPresent, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
if err != nil || ifPresent == nil || checks != 1 {
|
||||||
|
t.Fatalf("if-present load = %#v, %v; checks = %d", ifPresent, err, checks)
|
||||||
|
}
|
||||||
|
required, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if required != nil || !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeVersionError {
|
||||||
|
t.Fatalf("required load = %#v, %v", required, err)
|
||||||
|
}
|
||||||
|
if checks != 1 {
|
||||||
|
t.Fatalf("required absence reached checker: calls = %d", checks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const canary = "PRIVATE-SOURCE-VERSION-CANARY"
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "main.yml")
|
||||||
|
if err := os.WriteFile(path, []byte("version: "+canary+"\nvalue: visible\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var callbackSource configkit.SourceInfo
|
||||||
|
var callbackContext configkit.ResourceContext
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
|
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
|
||||||
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
var found bool
|
||||||
|
callbackSource, found = resolution.Source("source-1")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("resolution callback could not query the acquired source")
|
||||||
|
}
|
||||||
|
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||||
|
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck)
|
||||||
|
}
|
||||||
|
callbackContext, found = resolution.SourceContext("source-1")
|
||||||
|
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||||
|
t.Fatalf("callback source context = %#v, found=%v", callbackContext, found)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Destination: configkit.Keys("service")}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||||
|
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck)
|
||||||
|
}
|
||||||
|
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
|
||||||
|
callbackSource.VersionCheck.Version.Text = canary
|
||||||
|
callbackSource.VersionCheck.Location.Line = 98
|
||||||
|
finalSources := loaded.Sources()
|
||||||
|
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
|
||||||
|
t.Fatalf("final sources = %#v", finalSources)
|
||||||
|
}
|
||||||
|
check := finalSources[0].VersionCheck
|
||||||
|
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
|
||||||
|
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
|
||||||
|
t.Fatalf("final classified evidence = %#v", check)
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||||
|
t.Fatalf("resource metadata changed after removing the source: %#v", callbackContext)
|
||||||
|
}
|
||||||
|
check.Path = configkit.Keys("mutated")
|
||||||
|
check.Location.Line = 99
|
||||||
|
check.Redacted = false
|
||||||
|
check.Version.Text = "mutated"
|
||||||
|
repeated := loaded.Sources()
|
||||||
|
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" ||
|
||||||
|
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted ||
|
||||||
|
repeated[0].VersionCheck.Version.Text != "" {
|
||||||
|
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicText := "PUBLIC-SOURCE-VERSION"
|
||||||
|
var publicCallback configkit.SourceInfo
|
||||||
|
public, err := configkit.Load(configkit.Bytes("public", []byte("version: "+publicText+"\n")), configkit.Options[map[string]any]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
Version: definition.Version,
|
||||||
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
publicCallback, _ = resolution.Source("source-1")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
|
||||||
|
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
|
||||||
|
}
|
||||||
|
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
|
||||||
|
t.Fatalf("unclassified evidence = %#v", got)
|
||||||
|
}
|
||||||
|
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" {
|
||||||
|
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}}
|
||||||
|
t.Run("early import acquisition failure", func(t *testing.T) {
|
||||||
|
const canary = "EARLY-ACQUISITION-SOURCE-VERSION-CANARY"
|
||||||
|
checked := 0
|
||||||
|
earlyDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||||
|
}}
|
||||||
|
path := filepath.Join(t.TempDir(), "main.yml")
|
||||||
|
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n"
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Imports: true}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: earlyDefinition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||||
|
t.Fatalf("early acquisition failure sources = %#v, error %v", failure, err)
|
||||||
|
}
|
||||||
|
if checked != 1 {
|
||||||
|
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
||||||
|
}
|
||||||
|
check := failure.Sources()[0].VersionCheck
|
||||||
|
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||||
|
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("early acquisition failure disclosed source-version canary: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_, err := configkit.Load(configkit.Bytes("malformed", []byte("version: true\n")), configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("malformed version error = %v", err)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted ||
|
||||||
|
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" {
|
||||||
|
t.Fatalf("failed-load source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
sources[0].VersionCheck.Path = configkit.Keys("mutated-error")
|
||||||
|
sources[0].VersionCheck.Version.Text = "mutated"
|
||||||
|
sources[0].VersionCheck.Location.Line = 99
|
||||||
|
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
||||||
|
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
def configkit.Definition[map[string]any]
|
||||||
|
canary string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "resolver failure",
|
||||||
|
canary: "RESOLVER-SOURCE-VERSION-CANARY",
|
||||||
|
def: configkit.Definition[map[string]any]{Version: definition.Version, Resolve: func(*map[string]any, *configkit.Resolution) error {
|
||||||
|
return errors.New("resolver failed")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "validator failure",
|
||||||
|
canary: "VALIDATOR-SOURCE-VERSION-CANARY",
|
||||||
|
def: configkit.Definition[map[string]any]{Version: definition.Version, Validate: func(_ *map[string]any, validation *configkit.Validation) {
|
||||||
|
validation.Error(configkit.Keys("value"), "invalid value")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := configkit.Load(configkit.Bytes("late-failure", []byte("version: "+test.canary+"\nvalue: configured\n")), configkit.Options[map[string]any]{
|
||||||
|
Definition: test.def, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||||
|
t.Fatalf("late failure sources = %#v, error %v", failure, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), test.canary) {
|
||||||
|
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
||||||
|
}
|
||||||
|
check := failure.Sources()[0].VersionCheck
|
||||||
|
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||||
|
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
|
||||||
|
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil {
|
||||||
|
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withCLI, err := configkit.Load(configkit.Bytes("main", []byte("version: 8\nvalue: old\n")), configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Overrides: []configkit.Override{{Path: configkit.Keys("value"), Value: "new", Flag: "--value"}},
|
||||||
|
})
|
||||||
|
if err != nil || withCLI == nil {
|
||||||
|
t.Fatalf("enabled load with CLI source = %#v, %v", withCLI, err)
|
||||||
|
}
|
||||||
|
sawCLI := false
|
||||||
|
for _, source := range withCLI.Sources() {
|
||||||
|
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
|
||||||
|
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
||||||
|
}
|
||||||
|
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
||||||
|
}
|
||||||
|
if !sawCLI {
|
||||||
|
t.Fatalf("CLI source record missing: %#v", withCLI.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var checked []configkit.Version
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: 17\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checked = append(checked, version)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
{Source: configkit.Bytes("absent", []byte("value: visible\n")), Destination: configkit.Keys("absent")},
|
||||||
|
{Source: configkit.Bytes("null", []byte("version: null\nvalue: visible\n")), Destination: configkit.Keys("nullable")},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
if len(checked) != 2 || !checked[0].Present || checked[0].Text != "17" ||
|
||||||
|
!checked[1].Present || !checked[1].Null {
|
||||||
|
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil {
|
||||||
|
t.Fatalf("source version evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
absent := sources[1].VersionCheck
|
||||||
|
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
||||||
|
t.Fatalf("optional omission evidence = %#v", absent)
|
||||||
|
}
|
||||||
|
null := sources[2].VersionCheck
|
||||||
|
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
||||||
|
t.Fatalf("null declaration evidence = %#v", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *testing.T) {
|
||||||
|
type serviceConfig struct {
|
||||||
|
Version uint64 `yaml:"version"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Nullable *string `yaml:"nullable"`
|
||||||
|
Revision string `yaml:"revision"`
|
||||||
|
Defaulted string `yaml:"defaulted"`
|
||||||
|
Resolved string `yaml:"resolved"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Service serviceConfig `yaml:"service"`
|
||||||
|
}
|
||||||
|
t.Setenv("CONFIGKIT_LIFECYCLE_SECRET", "lifecycle-secret-value")
|
||||||
|
var checked []configkit.Version
|
||||||
|
var validationCalls int
|
||||||
|
definition := configkit.Definition[configuration]{
|
||||||
|
Defaults: func() configuration {
|
||||||
|
return configuration{Service: serviceConfig{Defaulted: "from defaults"}}
|
||||||
|
},
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
checked = append(checked, version)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||||
|
current.Service.Resolved = "resolved once"
|
||||||
|
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(current *configuration, validation *configkit.Validation) {
|
||||||
|
validationCalls++
|
||||||
|
if current.Service.Version != 9007199254740993 || current.Service.Count != 0 || current.Service.Enabled ||
|
||||||
|
current.Service.Nullable != nil || current.Service.Revision != "ordinary-payload" || current.Service.Defaulted != "from defaults" ||
|
||||||
|
current.Service.Resolved != "resolved once" || current.Service.Credential.Value() != "lifecycle-secret-value" {
|
||||||
|
validation.Error(configkit.Keys("service"), "assembled values did not survive source declaration processing")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
load := func(header string) *configkit.Result[configuration] {
|
||||||
|
t.Helper()
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("version: 9007199254740993\n")), Destination: configkit.Keys("service"),
|
||||||
|
}, configkit.LayeredOptions[configuration]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("underlay", []byte("version: "+header+"\ncount: 0\nenabled: false\nnullable: null\nrevision: ordinary-payload\ncredential: CONFIGKIT_LIFECYCLE_SECRET\n")),
|
||||||
|
Destination: configkit.Keys("service"),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
if err != nil || result == nil {
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
var diagnostics []configkit.Diagnostic
|
||||||
|
if errors.As(err, &failure) {
|
||||||
|
diagnostics = failure.Diagnostics()
|
||||||
|
}
|
||||||
|
t.Fatalf("LoadLayered(%q) = %#v, %v; diagnostics %#v", header, result, err, diagnostics)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
first := load("underlay-release-a")
|
||||||
|
second := load("underlay-release-b")
|
||||||
|
if len(checked) != 4 || checked[0].Text != "9007199254740993" || checked[1].Text != "underlay-release-a" ||
|
||||||
|
checked[2].Text != "9007199254740993" || checked[3].Text != "underlay-release-b" {
|
||||||
|
t.Fatalf("source declarations checked = %#v", checked)
|
||||||
|
}
|
||||||
|
if validationCalls != 2 || first.Config.Service != second.Config.Service {
|
||||||
|
t.Fatalf("final configs or validation count = %#v, %#v, %d", first.Config, second.Config, validationCalls)
|
||||||
|
}
|
||||||
|
if differences, err := configkit.Diff(first, second); err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("different consumed declarations created effective differences: %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
var firstEffective, secondEffective bytes.Buffer
|
||||||
|
if err := first.WriteEffective(&firstEffective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := second.WriteEffective(&secondEffective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if firstEffective.String() != secondEffective.String() || strings.Contains(firstEffective.String(), "underlay-release") {
|
||||||
|
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
|
||||||
|
}
|
||||||
|
sourcesA, sourcesB := first.Sources(), second.Sources()
|
||||||
|
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil ||
|
||||||
|
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" {
|
||||||
|
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
|
||||||
|
}
|
||||||
|
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
|
||||||
|
if !found {
|
||||||
|
t.Fatal("retained main version missing from effective explanation")
|
||||||
|
}
|
||||||
|
underlayID := sourcesA[1].ID
|
||||||
|
for _, location := range versionExplanation.Origin.Locations {
|
||||||
|
if location.SourceID == underlayID {
|
||||||
|
t.Fatalf("consumed underlay declaration entered effective origin: %#v", versionExplanation.Origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, change := range versionExplanation.History {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
if location.SourceID == underlayID {
|
||||||
|
t.Fatalf("consumed underlay declaration entered effective history: %#v", versionExplanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,29 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|||||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||||
// origins retain every source that contributes to a composite mapping.
|
// origins retain every source that contributes to a composite mapping.
|
||||||
type composedDocument struct {
|
type composedDocument struct {
|
||||||
root *document.Node
|
root *document.Node
|
||||||
claims []compositionClaim
|
acquisition []acquisitionScope
|
||||||
origins map[string]Origin
|
claims []compositionClaim
|
||||||
overlays []overlayRecord
|
sequenceRanges []sequenceRange
|
||||||
applications []historyApplication
|
origins map[string]Origin
|
||||||
inputBytes int64
|
overlays []overlayRecord
|
||||||
inputNodes int
|
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
|
// 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;
|
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||||
// history application trees are immutable and retain their existing sharing.
|
// history application trees are immutable and retain their existing sharing.
|
||||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||||
|
if base == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||||
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),
|
applications: cloneHistoryApplications(base.applications),
|
||||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type compositionClaim struct {
|
type compositionClaim struct {
|
||||||
path Path
|
node *document.Node // borrowed immutable contribution, not the assembly tree
|
||||||
kind document.Kind
|
path Path
|
||||||
location Location
|
kind document.Kind
|
||||||
|
location Location
|
||||||
|
sourceIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
type compositionClaims struct {
|
type compositionClaims struct {
|
||||||
@@ -48,16 +71,32 @@ type compositionClaims struct {
|
|||||||
byPath map[string][]compositionClaim
|
byPath map[string][]compositionClaim
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sequenceRange records where one source's sequence items were assigned in the
|
||||||
|
// composed sequence at path. End is exclusive.
|
||||||
|
type sequenceRange struct {
|
||||||
|
sourceIndex int
|
||||||
|
path Path
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
// composeDocuments recursively joins disjoint mapping entries. Source order
|
// composeDocuments recursively joins disjoint mapping entries. Source order
|
||||||
// controls only output traversal and diagnostics: every non-mapping overlap is
|
// controls only output traversal and diagnostics: every non-mapping overlap is
|
||||||
// rejected before a result is assembled.
|
// rejected before a result is assembled.
|
||||||
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||||
|
return composeDocumentsWithPolicy(sequencePolicy{}, inputs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeDocumentsWithPolicy composes one strict tier using the registered
|
||||||
|
// sequence rules when policy contains registrations. The ordinary adapter above
|
||||||
|
// deliberately preserves the existing unregistered behavior for current callers.
|
||||||
|
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||||
if len(inputs) == 0 {
|
if len(inputs) == 0 {
|
||||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||||
for _, input := range inputs {
|
for sourceIndex, input := range inputs {
|
||||||
if input == nil || input.Kind != document.Mapping {
|
if input == nil || input.Kind != document.Mapping {
|
||||||
var location *Location
|
var location *Location
|
||||||
if input != nil {
|
if input != nil {
|
||||||
@@ -66,7 +105,7 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
|||||||
}
|
}
|
||||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
|
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
|
||||||
}
|
}
|
||||||
collected.append(input, Path{})
|
collected.append(input, Path{}, sourceIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
checked := make(map[string]struct{}, len(collected.byPath))
|
checked := make(map[string]struct{}, len(collected.byPath))
|
||||||
@@ -77,28 +116,52 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
|||||||
}
|
}
|
||||||
checked[pointer] = struct{}{}
|
checked[pointer] = struct{}{}
|
||||||
claims := collected.byPath[pointer]
|
claims := collected.byPath[pointer]
|
||||||
if len(claims) < 2 || allMappingClaims(claims) {
|
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
root := document.Clone(inputs[0])
|
root := document.Clone(inputs[0])
|
||||||
index := new(mappingIndex)
|
index := new(mappingIndex)
|
||||||
for _, input := range inputs[1:] {
|
for sourceIndex, input := range inputs[1:] {
|
||||||
mergeDisjointMappings(root, input, index)
|
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
|
||||||
}
|
}
|
||||||
applications := make([]historyApplication, 0, len(inputs))
|
applications := make([]historyApplication, 0, len(inputs))
|
||||||
for _, input := range inputs {
|
seenSequences := make(map[string]bool)
|
||||||
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
rangesBySource := make([][]sequenceRange, len(inputs))
|
||||||
|
for _, assigned := range ranges {
|
||||||
|
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
|
||||||
}
|
}
|
||||||
origins := compositionOrigins(collected.ordered)
|
for sourceIndex, input := range inputs {
|
||||||
|
application := historyApplication{layer: LayerBase, value: input}
|
||||||
|
for _, assigned := range rangesBySource[sourceIndex] {
|
||||||
|
pointer := assigned.path.String()
|
||||||
|
mode := sequenceHistoryAppend
|
||||||
|
if !seenSequences[pointer] {
|
||||||
|
mode = sequenceHistoryReplace
|
||||||
|
}
|
||||||
|
seenSequences[pointer] = true
|
||||||
|
application.sequences = append(application.sequences, sequenceHistoryContribution{
|
||||||
|
path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||||
|
applications = append(applications, application)
|
||||||
|
}
|
||||||
|
origins := projectedCompositionOrigins(inputs, ranges)
|
||||||
completeCompositionOrigins(root, Path{}, origins)
|
completeCompositionOrigins(root, Path{}, origins)
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: root,
|
root: root,
|
||||||
claims: cloneCompositionClaims(collected.ordered),
|
claims: cloneCompositionClaims(collected.ordered),
|
||||||
origins: origins,
|
sequenceRanges: cloneSequenceRanges(ranges),
|
||||||
applications: applications,
|
origins: origins,
|
||||||
|
applications: applications,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,11 +184,13 @@ func completeCompositionOrigins(node *document.Node, path Path, origins map[stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *compositionClaims) append(node *document.Node, path Path) {
|
func (c *compositionClaims) append(node *document.Node, path Path, sourceIndex int) {
|
||||||
claim := compositionClaim{
|
claim := compositionClaim{
|
||||||
path: path.clone(),
|
node: node,
|
||||||
kind: node.Kind,
|
path: path.clone(),
|
||||||
location: publicLocation(node.Location),
|
kind: node.Kind,
|
||||||
|
location: publicLocation(node.Location),
|
||||||
|
sourceIndex: sourceIndex,
|
||||||
}
|
}
|
||||||
c.ordered = append(c.ordered, claim)
|
c.ordered = append(c.ordered, claim)
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
@@ -135,10 +200,146 @@ func (c *compositionClaims) append(node *document.Node, path Path) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
c.append(entry.Value, path.Key(entry.Key))
|
c.append(entry.Value, path.Key(entry.Key), sourceIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allSequenceClaims(claims []compositionClaim) bool {
|
||||||
|
for _, claim := range claims {
|
||||||
|
if claim.kind != document.Sequence {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type validatedSequence struct {
|
||||||
|
items []*document.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
|
||||||
|
itemsByPath := make(map[string]map[int]validatedSequence)
|
||||||
|
identitiesByPath := make(map[string]map[string]Location)
|
||||||
|
lengthsByPath := make(map[string]int)
|
||||||
|
var ranges []sequenceRange
|
||||||
|
for _, claim := range claims {
|
||||||
|
if claim.kind != document.Sequence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
identityField, registered := policy.identityFields[claim.path.String()]
|
||||||
|
if !registered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node := claim.node
|
||||||
|
pathKey := claim.path.String()
|
||||||
|
identities := identitiesByPath[pathKey]
|
||||||
|
if identities == nil {
|
||||||
|
identities = make(map[string]Location, len(node.Sequence))
|
||||||
|
identitiesByPath[pathKey] = identities
|
||||||
|
}
|
||||||
|
start := lengthsByPath[pathKey]
|
||||||
|
contribution := validatedSequence{items: node.Sequence}
|
||||||
|
for itemIndex, item := range node.Sequence {
|
||||||
|
itemPath := claim.path.Index(start + itemIndex)
|
||||||
|
if item == nil || item.Kind != document.Mapping {
|
||||||
|
location := (*Location)(nil)
|
||||||
|
if item != nil {
|
||||||
|
converted := publicLocation(item.Location)
|
||||||
|
location = &converted
|
||||||
|
}
|
||||||
|
return nil, nil, []Diagnostic{{
|
||||||
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||||
|
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
fieldIndex := documentEntryIndex(item.Mapping, identityField)
|
||||||
|
if fieldIndex < 0 {
|
||||||
|
location := publicLocation(item.Location)
|
||||||
|
return nil, nil, []Diagnostic{{
|
||||||
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||||
|
Path: itemPath, Message: "registered sequence item is missing its identity field",
|
||||||
|
Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
field := item.Mapping[fieldIndex].Value
|
||||||
|
if field == nil || field.Kind != document.String {
|
||||||
|
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
|
||||||
|
if field != nil {
|
||||||
|
location = publicLocation(field.Location)
|
||||||
|
}
|
||||||
|
return nil, nil, []Diagnostic{{
|
||||||
|
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||||
|
Path: itemPath.Key(identityField), Message: "registered sequence identity must be a string",
|
||||||
|
Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if first, duplicate := identities[field.Text]; duplicate {
|
||||||
|
current := publicLocation(field.Location)
|
||||||
|
return nil, nil, []Diagnostic{{
|
||||||
|
Code: CodeComposeConflict, Severity: SeverityError,
|
||||||
|
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
|
||||||
|
Primary: &first, Related: []Location{current},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
identities[field.Text] = publicLocation(field.Location)
|
||||||
|
}
|
||||||
|
if itemsByPath[pathKey] == nil {
|
||||||
|
itemsByPath[pathKey] = make(map[int]validatedSequence)
|
||||||
|
}
|
||||||
|
itemsByPath[pathKey][claim.sourceIndex] = contribution
|
||||||
|
lengthsByPath[pathKey] = start + len(node.Sequence)
|
||||||
|
ranges = append(ranges, sequenceRange{
|
||||||
|
sourceIndex: claim.sourceIndex, path: claim.path.clone(), start: start, end: start + len(node.Sequence),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return itemsByPath, ranges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
||||||
|
for _, incomingEntry := range incoming.Mapping {
|
||||||
|
entryPath := path.Key(incomingEntry.Key)
|
||||||
|
position := index.find(target, incomingEntry.Key)
|
||||||
|
if position < 0 {
|
||||||
|
cloned := incomingEntry
|
||||||
|
cloned.Value = document.Clone(incomingEntry.Value)
|
||||||
|
index.append(target, cloned)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current := target.Mapping[position].Value
|
||||||
|
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
|
||||||
|
contribution := validated[entryPath.String()][sourceIndex]
|
||||||
|
if contribution.items != nil {
|
||||||
|
for _, item := range contribution.items {
|
||||||
|
current.Sequence = append(current.Sequence, document.Clone(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
||||||
|
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p sequencePolicy) registered(path Path) bool {
|
||||||
|
_, ok := p.identityFields[path.String()]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
||||||
|
cloned := make([]sequenceRange, len(ranges))
|
||||||
|
for i, assigned := range ranges {
|
||||||
|
cloned[i] = assigned
|
||||||
|
cloned[i].path = assigned.path.clone()
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func allMappingClaims(claims []compositionClaim) bool {
|
func allMappingClaims(claims []compositionClaim) bool {
|
||||||
for _, claim := range claims {
|
for _, claim := range claims {
|
||||||
if claim.kind != document.Mapping {
|
if claim.kind != document.Mapping {
|
||||||
@@ -172,18 +373,64 @@ func documentEntryIndex(entries []document.Entry, key string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
func compositionOrigins(claims []compositionClaim) map[string]Origin {
|
func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
|
||||||
origins := make(map[string]Origin, len(claims))
|
origins := make(map[string]Origin)
|
||||||
for _, claim := range claims {
|
rangeBySourcePath := make(map[sequenceRangeKey]int, len(ranges))
|
||||||
pointer := claim.path.String()
|
for index, assigned := range ranges {
|
||||||
origin := origins[pointer]
|
rangeBySourcePath[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = index
|
||||||
origin.Layer = LayerBase
|
}
|
||||||
origin.Locations = append(origin.Locations, claim.location)
|
for sourceIndex, input := range inputs {
|
||||||
origins[pointer] = origin
|
collectProjectedCompositionOrigins(input, Path{}, Path{}, sourceIndex, ranges, rangeBySourcePath, origins)
|
||||||
}
|
}
|
||||||
return origins
|
return origins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sequenceRangeKey struct {
|
||||||
|
sourceIndex int
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type sequenceRangeIndex map[sequenceRangeKey]sequenceRange
|
||||||
|
|
||||||
|
func indexSequenceRanges(ranges []sequenceRange) sequenceRangeIndex {
|
||||||
|
if len(ranges) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index := make(sequenceRangeIndex, len(ranges))
|
||||||
|
for _, assigned := range ranges {
|
||||||
|
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep local paths for matching Stage 2 ranges and effective paths for emitted
|
||||||
|
// evidence. The paths are transformed segment by segment; pointer strings are
|
||||||
|
// only used as map keys after projection is complete.
|
||||||
|
func collectProjectedCompositionOrigins(node *document.Node, localPath, effectivePath Path, sourceIndex int, ranges []sequenceRange, rangeBySourcePath map[sequenceRangeKey]int, origins map[string]Origin) {
|
||||||
|
pointer := effectivePath.String()
|
||||||
|
origin := origins[pointer]
|
||||||
|
origin.Layer = LayerBase
|
||||||
|
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
||||||
|
origins[pointer] = origin
|
||||||
|
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
collectProjectedCompositionOrigins(entry.Value, localPath.Key(entry.Key), effectivePath.Key(entry.Key), sourceIndex, ranges, rangeBySourcePath, origins)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
start := 0
|
||||||
|
if rangeIndex, ok := rangeBySourcePath[sequenceRangeKey{sourceIndex: sourceIndex, path: localPath.String()}]; ok {
|
||||||
|
start = ranges[rangeIndex].start
|
||||||
|
}
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
localChild := localPath.Index(index)
|
||||||
|
effectiveChild := effectivePath.Index(start + index)
|
||||||
|
collectProjectedCompositionOrigins(child, localChild, effectiveChild, sourceIndex, ranges, rangeBySourcePath, origins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
||||||
for pointer, origin := range origins {
|
for pointer, origin := range origins {
|
||||||
if _, present := decoded.presence[pointer]; present {
|
if _, present := decoded.presence[pointer]; present {
|
||||||
|
|||||||
@@ -1,12 +1,60 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
|
||||||
|
policy := sequencePolicy{identityFields: make(map[string]string)}
|
||||||
|
var inputs []*document.Node
|
||||||
|
for source := 0; source < 3; source++ {
|
||||||
|
var text strings.Builder
|
||||||
|
for collection := 0; collection < 128; collection++ {
|
||||||
|
key := fmt.Sprintf("records%d", collection)
|
||||||
|
policy.identityFields[Keys(key).String()] = "id"
|
||||||
|
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
|
||||||
|
}
|
||||||
|
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
|
||||||
|
}
|
||||||
|
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
working := &document.Node{Kind: document.Mapping}
|
||||||
|
for _, application := range composed.applications {
|
||||||
|
applyHistoryApplication(&working, application, new(mappingIndex))
|
||||||
|
}
|
||||||
|
if !configurationValuesEqual(working, composed.root) {
|
||||||
|
t.Fatal("wide registered collections replay differs from assembly")
|
||||||
|
}
|
||||||
|
for collection := 0; collection < 128; collection++ {
|
||||||
|
path := Keys(fmt.Sprintf("records%d", collection)).Index(2).Key("id")
|
||||||
|
origin := composed.origins[path.String()]
|
||||||
|
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
|
||||||
|
t.Fatalf("shifted origin at %s = %#v", path.String(), origin)
|
||||||
|
}
|
||||||
|
local, ok := historyApplicationLocalPath(composed.applications[2], path)
|
||||||
|
if !ok || local.String() != Keys(fmt.Sprintf("records%d", collection)).Index(0).Key("id").String() {
|
||||||
|
t.Fatal("wide local path projection failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clone := cloneComposedDocument(composed)
|
||||||
|
for _, original := range composed.applications[0].sequences {
|
||||||
|
pointer := original.path.String()
|
||||||
|
copy := clone.applications[0].sequencesByPath[pointer]
|
||||||
|
copy.path.segments[0].key = "changed"
|
||||||
|
if composed.applications[0].sequencesByPath[pointer].path.String() != pointer {
|
||||||
|
t.Fatal("history path index aliases cloned state")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
|
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
|
||||||
base := parseCompositionDocument(t, "source-1", `services:
|
base := parseCompositionDocument(t, "source-1", `services:
|
||||||
api:
|
api:
|
||||||
@@ -190,6 +238,268 @@ func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T) {
|
||||||
|
first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n")
|
||||||
|
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
|
||||||
|
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
||||||
|
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
|
||||||
|
|
||||||
|
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
records, ok := documentAtPath(composed.root, Keys("records"))
|
||||||
|
if !ok || records.Kind != document.Sequence || len(records.Sequence) != 3 {
|
||||||
|
t.Fatalf("records = %#v, want three appended records", records)
|
||||||
|
}
|
||||||
|
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
|
||||||
|
{sourceIndex: 0, path: Keys("records"), start: 0, end: 2},
|
||||||
|
{sourceIndex: 1, path: Keys("records"), start: 2, end: 3},
|
||||||
|
}) {
|
||||||
|
t.Fatalf("sequence ranges = %#v", got)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
|
||||||
|
t.Fatal("registered composition mutated an input")
|
||||||
|
}
|
||||||
|
composed.root.Mapping[0].Value.Sequence[0].Mapping[1].Value.Text = "changed"
|
||||||
|
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
|
||||||
|
t.Fatal("composed sequence aliases an input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsProjectsSequenceOriginsToAssignedIndexes(t *testing.T) {
|
||||||
|
first := parseCompositionDocument(t, "source-1", `mounted:
|
||||||
|
records:
|
||||||
|
- id: first
|
||||||
|
name: first-name
|
||||||
|
escaped/~:
|
||||||
|
"7":
|
||||||
|
- id: escaped-first
|
||||||
|
name: escaped-first-name
|
||||||
|
`)
|
||||||
|
second := parseCompositionDocument(t, "source-2", `mounted:
|
||||||
|
records:
|
||||||
|
- id: second
|
||||||
|
name: second-name
|
||||||
|
escaped/~:
|
||||||
|
"7":
|
||||||
|
- id: escaped-second
|
||||||
|
name: escaped-second-name
|
||||||
|
`)
|
||||||
|
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
||||||
|
policy := sequencePolicy{identityFields: map[string]string{
|
||||||
|
Keys("mounted", "records").String(): "id",
|
||||||
|
Keys("escaped/~", "7").String(): "id",
|
||||||
|
}}
|
||||||
|
|
||||||
|
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path Path
|
||||||
|
want Location
|
||||||
|
}{
|
||||||
|
{path: Keys("mounted", "records").Index(1).Key("name"), want: Location{SourceID: "source-2", Line: 4, Column: 13}},
|
||||||
|
{path: Keys("escaped/~", "7").Index(1).Key("name"), want: Location{SourceID: "source-2", Line: 8, Column: 13}},
|
||||||
|
} {
|
||||||
|
path, want := test.path, test.want
|
||||||
|
origin, ok := composed.origins[path.String()]
|
||||||
|
if !ok || !reflect.DeepEqual(origin.Locations, []Location{want}) {
|
||||||
|
t.Errorf("origin at %s = %#v, want source-specific location %#v", path, origin, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path Path
|
||||||
|
wantSources []string
|
||||||
|
}{
|
||||||
|
{path: Keys("mounted", "records"), wantSources: []string{"source-1", "source-2"}},
|
||||||
|
{path: Keys("escaped/~", "7"), wantSources: []string{"source-1", "source-2"}},
|
||||||
|
} {
|
||||||
|
path, wantSources := test.path, test.wantSources
|
||||||
|
origin := composed.origins[path.String()]
|
||||||
|
gotSources := make([]string, len(origin.Locations))
|
||||||
|
for index, location := range origin.Locations {
|
||||||
|
gotSources[index] = location.SourceID
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotSources, wantSources) {
|
||||||
|
t.Errorf("container origin at %s = %#v, want contributors %#v", path, gotSources, wantSources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
|
||||||
|
t.Fatal("composition projection mutated an input")
|
||||||
|
}
|
||||||
|
|
||||||
|
copy := cloneComposedDocument(composed)
|
||||||
|
copy.origins[Keys("mounted", "records").Index(1).Key("name").String()].Locations[0].SourceID = "changed"
|
||||||
|
for index := range copy.claims {
|
||||||
|
if copy.claims[index].path.String() == "/mounted/records" {
|
||||||
|
copy.claims[index].path.segments[0].key = "changed"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy.sequenceRanges[0].path.segments[0].key = "changed"
|
||||||
|
if got := composed.origins[Keys("mounted", "records").Index(1).Key("name").String()].Locations[0].SourceID; got != "source-2" {
|
||||||
|
t.Fatalf("copied origin mutation changed source evidence: %q", got)
|
||||||
|
}
|
||||||
|
foundClaim := false
|
||||||
|
for _, claim := range composed.claims {
|
||||||
|
if claim.path.String() == "/mounted/records" {
|
||||||
|
foundClaim = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundClaim {
|
||||||
|
t.Fatal("copied claim mutation changed source path")
|
||||||
|
}
|
||||||
|
if got := composed.sequenceRanges[0].path.String(); got != "/mounted/records" {
|
||||||
|
t.Fatalf("copied range mutation changed assigned path: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
path string
|
||||||
|
wantCode Code
|
||||||
|
}{
|
||||||
|
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
||||||
|
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
||||||
|
{name: "null identity", body: "records: [{id: null}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||||
|
{name: "numeric identity", body: "records: [{id: 12}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||||
|
{name: "boolean identity", body: "records: [{id: false}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, diagnostics := composeDocumentsWithPolicy(
|
||||||
|
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||||
|
parseCompositionDocument(t, "source-1", test.body),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %s at %s", diagnostics, test.wantCode, test.path)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil {
|
||||||
|
t.Fatal("malformed sequence diagnostic has no source location")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
|
||||||
|
_, diagnostics := composeDocumentsWithPolicy(
|
||||||
|
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" {
|
||||||
|
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||||
|
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
first string
|
||||||
|
second string
|
||||||
|
within bool
|
||||||
|
}{
|
||||||
|
{name: "within identical bodies", first: "records: [{id: '', value: same}, {id: '', value: same}]\n", within: true},
|
||||||
|
{name: "within disjoint bodies", first: "records: [{id: same, left: 1}, {id: same, right: 2}]\n", within: true},
|
||||||
|
{name: "across identical bodies", first: "records: [{id: same, value: same}]\n", second: "records: [{id: same, value: same}]\n"},
|
||||||
|
{name: "across disjoint bodies", first: "records: [{id: same, left: 1}]\n", second: "records: [{id: same, right: 2}]\n"},
|
||||||
|
{name: "empty identity across lists", first: "records: [{id: ''}]\n", second: "records: [{id: ''}]\n"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
inputs := []*document.Node{parseCompositionDocument(t, "source-1", test.first)}
|
||||||
|
if !test.within {
|
||||||
|
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
|
||||||
|
}
|
||||||
|
_, diagnostics := composeDocumentsWithPolicy(
|
||||||
|
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs...,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||||
|
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
|
||||||
|
t.Fatalf("duplicate locations = %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"same", "left", "right", "value"} {
|
||||||
|
if strings.Contains(diagnostics[0].Message, secret) {
|
||||||
|
t.Fatalf("diagnostic leaks %q: %q", secret, diagnostics[0].Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
|
||||||
|
policy := sequencePolicy{identityFields: map[string]string{
|
||||||
|
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
|
||||||
|
}}
|
||||||
|
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
|
||||||
|
not-a-sequence: null
|
||||||
|
catalog:
|
||||||
|
records: [{id: present}]
|
||||||
|
`))
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(composed.sequenceRanges) != 1 || composed.sequenceRanges[0].path.String() != "/catalog/records" {
|
||||||
|
t.Fatalf("sequence ranges = %#v, want only the reached descendant sequence", composed.sequenceRanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
composed, diagnostics = composeDocumentsWithPolicy(
|
||||||
|
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||||
|
parseCompositionDocument(t, "source-1", "records: []\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "records: []\n"),
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 || len(composed.root.Mapping[0].Value.Sequence) != 0 {
|
||||||
|
t.Fatalf("empty registered sequences = (%#v, %#v)", composed, diagnostics)
|
||||||
|
}
|
||||||
|
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
|
||||||
|
{sourceIndex: 0, path: Keys("records"), start: 0, end: 0},
|
||||||
|
{sourceIndex: 1, path: Keys("records"), start: 0, end: 0},
|
||||||
|
}) {
|
||||||
|
t.Fatalf("empty sequence ranges = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
|
||||||
|
_, diagnostics := composeDocumentsWithPolicy(
|
||||||
|
sequencePolicy{identityFields: map[string]string{"/registered": "id"}},
|
||||||
|
parseCompositionDocument(t, "source-1", "unregistered: [one]\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
|
||||||
|
)
|
||||||
|
assertCompositionConflict(t, diagnostics, "/unregistered", []string{"source-1", "source-2"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
first string
|
||||||
|
second string
|
||||||
|
}{
|
||||||
|
{name: "mapping and sequence", first: "records: {id: one}\n", second: "records: [{id: two}]\n"},
|
||||||
|
{name: "null and sequence", first: "records: null\n", second: "records: [{id: two}]\n"},
|
||||||
|
{name: "scalar and sequence", first: "records: value\n", second: "records: [{id: two}]\n"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, diagnostics := composeDocumentsWithPolicy(
|
||||||
|
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||||
|
parseCompositionDocument(t, "source-1", test.first),
|
||||||
|
parseCompositionDocument(t, "source-2", test.second),
|
||||||
|
)
|
||||||
|
assertCompositionConflict(t, diagnostics, "/records", []string{"source-1", "source-2"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
|
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
|
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
|
||||||
|
|||||||
@@ -11,6 +11,33 @@ var (
|
|||||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
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) {
|
func normalizeDestination(path Path) (Path, error) {
|
||||||
if !path.valid() {
|
if !path.valid() {
|
||||||
return Path{}, errInvalidDestination
|
return Path{}, errInvalidDestination
|
||||||
@@ -29,6 +56,13 @@ func normalizeDestination(path Path) (Path, error) {
|
|||||||
return Path{segments: segments}, nil
|
return Path{segments: segments}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendDestination combines validated mapping-key paths with owned segments.
|
||||||
|
func appendDestination(parent, relative Path) Path {
|
||||||
|
segments := clonePathSegments(parent.segments, len(relative.segments))
|
||||||
|
segments = append(segments, relative.segments...)
|
||||||
|
return Path{segments: segments}
|
||||||
|
}
|
||||||
|
|
||||||
func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||||
return Diagnostic{
|
return Diagnostic{
|
||||||
Code: CodeOptionsInvalid, Severity: SeverityError,
|
Code: CodeOptionsInvalid, Severity: SeverityError,
|
||||||
@@ -37,27 +71,42 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSourceDestinations(sources []SourceInfo, destination Path) {
|
|
||||||
for index := range sources {
|
|
||||||
sources[index].Destination = destination.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// placeDocument checks all per-document bounds before allocating destination
|
// placeDocument checks all per-document bounds before allocating destination
|
||||||
// wrappers. Aggregate accounting remains the caller's responsibility.
|
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||||
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||||
|
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
normalized, err := normalizeDestination(destination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Path{}, nil, err
|
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 {
|
if body == nil {
|
||||||
return normalized, nil, 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
|
return normalized, nil, errDestinationLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
placed := body
|
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-- {
|
for index := len(normalized.segments) - 1; index >= 0; index-- {
|
||||||
key := normalized.segments[index].key
|
key := normalized.segments[index].key
|
||||||
placed = &document.Node{
|
placed = &document.Node{
|
||||||
@@ -76,28 +125,49 @@ func placeDocument(body *document.Node, destination Path, sourceRoot document.Lo
|
|||||||
|
|
||||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||||
|
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
|
||||||
if body == nil {
|
if body == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() {
|
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
segments := len(destination.segments)
|
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
|
return false
|
||||||
}
|
}
|
||||||
wrapperNodes := destinationWrapperNodeCount(destination)
|
wrapperNodes := destinationWrapperNodeCount(destination) + sequenceNodes
|
||||||
for _, segment := range destination.segments {
|
for _, segment := range destination.segments {
|
||||||
if segment.kind != pathSegmentKey {
|
if segment.kind != pathSegmentKey {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if documentDepth(body) > maxDepth-segments {
|
if documentDepth(body) > maxDepth-segments-sequenceDepth {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
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 {
|
func destinationWrapperNodeCount(destination Path) int {
|
||||||
return 2 * len(destination.segments)
|
return 2 * len(destination.segments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
@@ -66,6 +68,66 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNestedItemGlobsShareUnderlayNodeBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- glob: children/*.yml
|
||||||
|
destination: [children]
|
||||||
|
placement: sequence-item
|
||||||
|
id: parent
|
||||||
|
`)
|
||||||
|
fragmentPath := writeImportFile(t, directory, "children/a.yml", "id: child-a\n")
|
||||||
|
parseNodes := func(path string) int {
|
||||||
|
t.Helper()
|
||||||
|
parsed, diagnostics := parseSource(File(path), "fixture", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("parse %s: %#v", path, diagnostics)
|
||||||
|
}
|
||||||
|
return countInputNodes(parsed.document)
|
||||||
|
}
|
||||||
|
destination := Keys("groups")
|
||||||
|
childDestination := appendDestination(destination, Keys("children"))
|
||||||
|
otherDestination := Keys("other")
|
||||||
|
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
||||||
|
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
|
||||||
|
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
|
||||||
|
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
delta int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "at aggregate bound"},
|
||||||
|
{name: "one node below aggregate bound", delta: 1, wantError: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
||||||
|
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||||
|
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
|
||||||
|
}, {
|
||||||
|
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
|
||||||
|
}}}, 1, nil, accounting)
|
||||||
|
if test.wantError {
|
||||||
|
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||||
|
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
|
||||||
|
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||||
|
}
|
||||||
|
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||||
|
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -120,6 +182,207 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: empty.yml
|
||||||
|
destination: [groups, empty]
|
||||||
|
`)
|
||||||
|
fragmentPath := writeImportFile(t, directory, "empty.yml", "{}\n")
|
||||||
|
main := parseImportMain(t, mainPath)
|
||||||
|
fragment, diagnostics := parseSource(File(fragmentPath), "source-2", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("empty fragment diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
entryDestination := Keys("collections", "entry")
|
||||||
|
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
|
||||||
|
wantNodes := countInputNodes(main.document) + countInputNodes(fragment.document) +
|
||||||
|
destinationWrapperNodeCount(entryDestination) + destinationWrapperNodeCount(importDestination)
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
delta int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "below aggregate bound", delta: -1, wantError: true},
|
||||||
|
{name: "at aggregate bound"},
|
||||||
|
{name: "above aggregate bound", delta: 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
currentLimits := limits
|
||||||
|
currentLimits.nodes = wantNodes + test.delta
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||||
|
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||||
|
)
|
||||||
|
if test.wantError {
|
||||||
|
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
|
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||||
|
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||||
|
}
|
||||||
|
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||||
|
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||||
|
}
|
||||||
|
if sources[1].Destination.String() != importDestination.String() {
|
||||||
|
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
|
||||||
|
}
|
||||||
|
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
|
t.Fatalf("empty relative import body = %#v, present %v", node, present)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- glob: fragments/*.yml
|
||||||
|
destination: [groups, empty]
|
||||||
|
`)
|
||||||
|
fragmentPaths := []string{
|
||||||
|
writeImportFile(t, directory, "fragments/a.yml", "{}\n"),
|
||||||
|
writeImportFile(t, directory, "fragments/b.yml", "{}\n"),
|
||||||
|
writeImportFile(t, directory, "fragments/c.yml", "{}\n"),
|
||||||
|
}
|
||||||
|
main := parseImportMain(t, mainPath)
|
||||||
|
fragment, diagnostics := parseSource(File(fragmentPaths[0]), "source-fragment", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("glob fragment diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
entryDestination := Keys("collections", "entry")
|
||||||
|
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
|
||||||
|
wantNodes := countInputNodes(main.document) + len(fragmentPaths)*countInputNodes(fragment.document) +
|
||||||
|
destinationWrapperNodeCount(entryDestination) + len(fragmentPaths)*destinationWrapperNodeCount(importDestination)
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
delta int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "below aggregate bound", delta: -1, wantError: true},
|
||||||
|
{name: "at aggregate bound"},
|
||||||
|
{name: "above aggregate bound", delta: 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
currentLimits := limits
|
||||||
|
currentLimits.nodes = wantNodes + test.delta
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||||
|
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||||
|
)
|
||||||
|
if test.wantError {
|
||||||
|
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||||
|
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 {
|
||||||
|
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||||
|
}
|
||||||
|
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||||
|
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||||
|
}
|
||||||
|
for index, source := range sources[1:] {
|
||||||
|
if source.Destination.String() != importDestination.String() {
|
||||||
|
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
|
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeImportDestinationUsesAccumulatedDocumentDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml, destination: [extra]}]}\n")
|
||||||
|
writeImportFile(t, directory, "fragment.yml", "value: nested\n")
|
||||||
|
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
|
||||||
|
for index := range destination.segments {
|
||||||
|
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
|
||||||
|
}
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Imports: true, Destination: destination}, testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{},
|
||||||
|
)
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportLimitExceeded {
|
||||||
|
t.Fatalf("accumulated depth result = %v", err)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || len(sources[0].Destination.segments) != maxDocumentDepth || len(sources[1].Destination.segments) != maxDocumentDepth+1 {
|
||||||
|
t.Fatalf("depth failure source destinations = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
|
||||||
|
for index := range destination.segments {
|
||||||
|
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
setup func(*testing.T, string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "optional missing path",
|
||||||
|
body: "configkit: {imports: [{path: missing.yml, optional: true, destination: [unused]}]}\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional unmatched glob",
|
||||||
|
body: "configkit: {imports: [{glob: empty/*.yml, optional: true, destination: [unused]}]}\n",
|
||||||
|
setup: func(t *testing.T, directory string) {
|
||||||
|
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
if test.setup != nil {
|
||||||
|
test.setup(t, directory)
|
||||||
|
}
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", test.body)
|
||||||
|
main := parseImportMain(t, mainPath)
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
accounting := &importAccounting{}
|
||||||
|
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||||
|
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
||||||
|
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||||
|
}
|
||||||
|
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
|
||||||
|
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||||
|
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||||
|
}
|
||||||
|
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||||
|
t.Fatalf("empty entry body = %#v, present %v", node, present)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
211
internal/engine/destination_import_lifecycle_test.go
Normal file
211
internal/engine/destination_import_lifecycle_test.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
Extends string `yaml:"extends"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
Token *Secret `yaml:"token" configkit:"secret"`
|
||||||
|
Dynamic string `yaml:"dynamic"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
Height int `yaml:"height"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Catalog struct {
|
||||||
|
Entries map[string]entry `yaml:"entries"`
|
||||||
|
} `yaml:"catalog"`
|
||||||
|
}
|
||||||
|
|
||||||
|
load := func(dynamic string) *testResult[configuration] {
|
||||||
|
t.Helper()
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "entry.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: parent.yml
|
||||||
|
destination: [entries]
|
||||||
|
- path: child.yml
|
||||||
|
destination: [entries]
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "parent.yml", `parent:
|
||||||
|
label: lower
|
||||||
|
token: PARENT_TOKEN
|
||||||
|
height: 1
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "child.yml", fmt.Sprintf(`child:
|
||||||
|
extends: parent
|
||||||
|
label: child
|
||||||
|
dynamic: %s
|
||||||
|
height: 9
|
||||||
|
`, dynamic))
|
||||||
|
|
||||||
|
var defaultsCalls, resolveCalls, validateCalls int
|
||||||
|
secretPaths := make(map[string]bool)
|
||||||
|
lookupCalls := make(map[string]int)
|
||||||
|
request := loadRequest[configuration]{
|
||||||
|
main: loadInput{
|
||||||
|
source: Bytes("main", []byte("parent: {label: final, height: 4}\n")),
|
||||||
|
destination: Keys("catalog", "entries"),
|
||||||
|
},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{
|
||||||
|
source: File(mainPath), imports: true, destination: Keys("catalog"),
|
||||||
|
}}}},
|
||||||
|
definition: Definition[configuration]{
|
||||||
|
Defaults: func() configuration {
|
||||||
|
defaultsCalls++
|
||||||
|
var defaults configuration
|
||||||
|
defaults.Catalog.Entries = map[string]entry{"fallback": {Label: "default"}}
|
||||||
|
return defaults
|
||||||
|
},
|
||||||
|
Resolve: func(config *configuration, resolution *Resolution) error {
|
||||||
|
resolveCalls++
|
||||||
|
child := config.Catalog.Entries["child"]
|
||||||
|
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
|
||||||
|
config.Catalog.Entries["child"] = child
|
||||||
|
resolution.Record(Keys("catalog", "entries", "child", "derived"), ChangeNormalization, "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(config *configuration, validation *Validation) {
|
||||||
|
validateCalls++
|
||||||
|
parent := config.Catalog.Entries["parent"]
|
||||||
|
child := config.Catalog.Entries["child"]
|
||||||
|
if parent.Label != "final" || parent.Height != 4 || child.Label != "child" || child.Height != 9 || child.Derived != child.Label+":"+child.Dynamic+":9" || child.Token == nil || !child.Token.IsResolved() || child.Token.Value() != "parent-secret-canary" {
|
||||||
|
validation.Error(Keys("catalog", "entries", "child"), "final imported configuration was not available to validation")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Secret: func(path Path) bool {
|
||||||
|
secretPaths[path.String()] = true
|
||||||
|
return path.String() == "/catalog/entries/child/dynamic"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
loaded, err := loadGroupedWithSecretLookup(request, testSecretBinding(), func(name string) (string, bool) {
|
||||||
|
lookupCalls[name]++
|
||||||
|
if name == "PARENT_TOKEN" {
|
||||||
|
return "parent-secret-canary", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*LoadError); ok {
|
||||||
|
t.Fatalf("relative import lifecycle diagnostics: %#v", loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("relative import lifecycle: %v", err)
|
||||||
|
}
|
||||||
|
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || lookupCalls["PARENT_TOKEN"] != 1 {
|
||||||
|
t.Fatalf("lifecycle calls defaults=%d resolve=%d validate=%d lookups=%#v", defaultsCalls, resolveCalls, validateCalls, lookupCalls)
|
||||||
|
}
|
||||||
|
if !secretPaths["/catalog/entries/child/dynamic"] {
|
||||||
|
t.Fatalf("secret classifier did not see effective paths: %#v", secretPaths)
|
||||||
|
}
|
||||||
|
return testLoaded(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
left := load("LEFT-DYNAMIC-CANARY")
|
||||||
|
child := left.Config.Catalog.Entries["child"]
|
||||||
|
if child.Extends != "" || child.Label != "child" || child.Height != 9 || child.Derived != "child:LEFT-DYNAMIC-CANARY:9" || child.Token == nil || child.Token.Value() != "parent-secret-canary" {
|
||||||
|
t.Fatalf("resolved imported child = %#v", child)
|
||||||
|
}
|
||||||
|
if left.Config.Catalog.Entries["parent"].Label != "final" || left.Config.Catalog.Entries["parent"].Height != 4 || left.Config.Catalog.Entries["fallback"].Label != "default" {
|
||||||
|
t.Fatalf("final parent/default shape = %#v", left.Config.Catalog.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
childTokenPath := Keys("catalog", "entries", "child", "token")
|
||||||
|
childToken, found := left.Inspection.Explain(childTokenPath)
|
||||||
|
childTokenInfo, tokenInfoFound := left.Inspection.Metadata().Lookup(childTokenPath)
|
||||||
|
if !found || !tokenInfoFound || !childToken.Effective.Redacted || strings.Contains(childToken.Effective.YAML, "parent-secret-canary") || len(childTokenInfo.Origin.Inheritance) != 1 {
|
||||||
|
t.Fatalf("inherited field explanation = %#v, %v", childToken, found)
|
||||||
|
}
|
||||||
|
var explanation bytes.Buffer
|
||||||
|
if err := left.Inspection.WriteExplanation(&explanation, childTokenPath); err != nil || strings.Contains(explanation.String(), "parent-secret-canary") || strings.Contains(explanation.String(), "LEFT-DYNAMIC-CANARY") {
|
||||||
|
t.Fatalf("safe explanation = %q, %v", explanation.String(), err)
|
||||||
|
}
|
||||||
|
labelHistory := left.Inspection.Metadata().History(Keys("catalog", "entries", "parent", "label"))
|
||||||
|
if len(labelHistory) < 2 {
|
||||||
|
t.Fatalf("parent label history omitted replaced contribution: %#v", labelHistory)
|
||||||
|
}
|
||||||
|
derived, found := left.Inspection.Explain(Keys("catalog", "entries", "child", "derived"))
|
||||||
|
if !found || !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "LEFT-DYNAMIC-CANARY") || len(derived.History) == 0 || derived.History[len(derived.History)-1].Origin.Operation != ChangeNormalization {
|
||||||
|
t.Fatalf("recorded derived field explanation = %#v, %v", derived, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
right := load("RIGHT-DYNAMIC-CANARY")
|
||||||
|
differences, err := Diff(left.Inspection, right.Inspection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, difference := range differences {
|
||||||
|
path := difference.Path.String()
|
||||||
|
seen[path] = true
|
||||||
|
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged || strings.Contains(difference.Left.YAML+difference.Right.YAML, "DYNAMIC-CANARY") || strings.Contains(difference.Left.YAML+difference.Right.YAML, "parent-secret-canary") {
|
||||||
|
t.Errorf("sensitive difference at %s = %#v", path, difference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/catalog/entries/child/dynamic", "/catalog/entries/child/derived"} {
|
||||||
|
if !seen[path] {
|
||||||
|
t.Errorf("missing inherited/derived difference at %s: %#v", path, differences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeImportMatchesWrappedPlacementAndSafeDiff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
load := func(relativeImport bool, value string) *testResult[map[string]any] {
|
||||||
|
t.Helper()
|
||||||
|
input := Input{Source: NoSource()}
|
||||||
|
options := LayeredOptions[map[string]any]{}
|
||||||
|
if relativeImport {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: fragment.yml
|
||||||
|
destination: [application]
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "fragment.yml", "field: "+value+"\n")
|
||||||
|
options.Underlays = []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}}
|
||||||
|
} else {
|
||||||
|
options.Underlays = []Underlay{{Inputs: []Input{{Source: Bytes("wrapped", []byte("application: {field: "+value+"}\n"))}}}}
|
||||||
|
}
|
||||||
|
loaded, err := LoadLayered(input, testSecretBinding(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
return testLoaded(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeBefore := load(true, "before")
|
||||||
|
relativeAfter := load(true, "after")
|
||||||
|
wrappedBefore := load(false, "before")
|
||||||
|
wrappedAfter := load(false, "after")
|
||||||
|
for _, pair := range [][2]*testResult[map[string]any]{{relativeBefore, wrappedBefore}, {relativeAfter, wrappedAfter}} {
|
||||||
|
if !reflect.DeepEqual(pair[0].Config, pair[1].Config) {
|
||||||
|
t.Fatalf("relative and wrapped configurations differ: %#v vs %#v", pair[0].Config, pair[1].Config)
|
||||||
|
}
|
||||||
|
for _, result := range pair {
|
||||||
|
if info, ok := result.Metadata().Lookup(Keys("application", "field")); !ok || !info.Explicit {
|
||||||
|
t.Fatalf("effective field presence = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relativeDiff, err := Diff(relativeBefore.Inspection, relativeAfter.Inspection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wrappedDiff, err := Diff(wrappedBefore.Inspection, wrappedAfter.Inspection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(relativeDiff, wrappedDiff) {
|
||||||
|
t.Fatalf("relative safe diff = %#v, wrapped safe diff = %#v", relativeDiff, wrappedDiff)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
type importDestinationConfig struct {
|
type importDestinationConfig struct {
|
||||||
@@ -71,6 +73,347 @@ first: one
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportTraversalUsesPerDeclarationDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: parent.yml
|
||||||
|
- path: sibling.yml
|
||||||
|
- glob: fragments/*.yml
|
||||||
|
`)
|
||||||
|
parentPath := writeImportFile(t, directory, "parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: nested/child.yml
|
||||||
|
parent: true
|
||||||
|
`)
|
||||||
|
childPath := writeImportFile(t, directory, "nested/child.yml", "child: true\n")
|
||||||
|
siblingPath := writeImportFile(t, directory, "sibling.yml", "sibling: true\n")
|
||||||
|
alphaPath := writeImportFile(t, directory, "fragments/a.yml", "alpha: true\n")
|
||||||
|
betaPath := writeImportFile(t, directory, "fragments/b.yml", "beta: true\n")
|
||||||
|
|
||||||
|
main := parseImportMain(t, mainPath)
|
||||||
|
_, declarations, diagnostics := splitImportDirectives(main.document)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("main directive diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
declarations[0].destination = Keys("parent")
|
||||||
|
declarations[1].destination = Keys("sibling")
|
||||||
|
declarations[2].destination = Keys("collection")
|
||||||
|
|
||||||
|
access, err := openHostAccess(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = access.close() }()
|
||||||
|
state := importTraversal{
|
||||||
|
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
|
||||||
|
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
|
||||||
|
}
|
||||||
|
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantSources := []struct {
|
||||||
|
path string
|
||||||
|
destination string
|
||||||
|
}{
|
||||||
|
{path: parentPath, destination: "/entry/parent"},
|
||||||
|
{path: childPath, destination: "/entry/parent"},
|
||||||
|
{path: siblingPath, destination: "/entry/sibling"},
|
||||||
|
{path: alphaPath, destination: "/entry/collection"},
|
||||||
|
{path: betaPath, destination: "/entry/collection"},
|
||||||
|
}
|
||||||
|
if len(state.sources) != len(wantSources) {
|
||||||
|
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
|
||||||
|
}
|
||||||
|
for index, want := range wantSources {
|
||||||
|
got := state.sources[index]
|
||||||
|
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
||||||
|
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
documents := make([]*document.Node, 0, len(state.scope.contributions))
|
||||||
|
for _, contribution := range state.scope.contributions {
|
||||||
|
documents = append(documents, contribution.document)
|
||||||
|
}
|
||||||
|
composed, diagnostics := composeDocuments(documents...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("composition diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{
|
||||||
|
Keys("entry", "parent", "parent"), Keys("entry", "parent", "child"),
|
||||||
|
Keys("entry", "sibling", "sibling"), Keys("entry", "collection", "alpha"),
|
||||||
|
Keys("entry", "collection", "beta"),
|
||||||
|
} {
|
||||||
|
if _, present := documentAtPath(composed.root, path); !present {
|
||||||
|
t.Errorf("no composed value at %q", path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("import context failure", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||||
|
input, diagnostics := assembleBaseInputTracked(
|
||||||
|
File(mainPath),
|
||||||
|
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
|
||||||
|
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||||
|
t.Fatalf("context diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(input.sources) != 1 || input.sources[0].Destination.String() != "/mounted" {
|
||||||
|
t.Fatalf("entry source fallback = %#v", input.sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("imported parse failure", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "broken.yml", "broken: [\n")
|
||||||
|
input, diagnostics := assembleBaseInputTracked(
|
||||||
|
File(mainPath), Options[map[string]any]{Imports: true},
|
||||||
|
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
|
||||||
|
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(input.sources) != 2 {
|
||||||
|
t.Fatalf("parse-failure sources = %#v", input.sources)
|
||||||
|
}
|
||||||
|
for index, source := range input.sources {
|
||||||
|
if source.Destination.String() != "/mounted" {
|
||||||
|
t.Errorf("source %d destination = %q, want /mounted", index, source.Destination.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeImportDestinationsKeepStrictCompositionBoundaries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
main string
|
||||||
|
files map[string]string
|
||||||
|
want map[string]any
|
||||||
|
conflict bool
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disjoint peers at one destination",
|
||||||
|
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||||
|
files: map[string]string{"left.yml": "left: one\n", "right.yml": "right: two\n"},
|
||||||
|
want: map[string]any{"/application/left": "one", "/application/right": "two"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different destinations",
|
||||||
|
main: "configkit:\n imports:\n - path: left.yml\n destination: [left]\n - path: right.yml\n destination: [right]\n",
|
||||||
|
files: map[string]string{"left.yml": "value: shared\n", "right.yml": "value: shared\n"},
|
||||||
|
want: map[string]any{"/left/value": "shared", "/right/value": "shared"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ancestor and descendant overlap",
|
||||||
|
main: "configkit:\n imports:\n - path: parent.yml\n destination: [application]\n - path: child.yml\n destination: [application, branch]\n",
|
||||||
|
files: map[string]string{"parent.yml": "branch: {base: one}\n", "child.yml": "leaf: two\n"},
|
||||||
|
want: map[string]any{"/application/branch/base": "one", "/application/branch/leaf": "two"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collision with explicitly wrapped content",
|
||||||
|
main: "configkit: {imports: [{path: fragment.yml, destination: [application]}]}\napplication: {value: root}\n",
|
||||||
|
files: map[string]string{"fragment.yml": "value: imported\n"},
|
||||||
|
conflict: true,
|
||||||
|
wantPath: "/application/value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "equal peer values conflict",
|
||||||
|
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||||
|
files: map[string]string{"left.yml": "value: same\n", "right.yml": "value: same\n"},
|
||||||
|
conflict: true,
|
||||||
|
wantPath: "/application/value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unequal peer values conflict",
|
||||||
|
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||||
|
files: map[string]string{"left.yml": "value: left\n", "right.yml": "value: right\n"},
|
||||||
|
conflict: true,
|
||||||
|
wantPath: "/application/value",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", test.main)
|
||||||
|
for name, body := range test.files {
|
||||||
|
writeImportFile(t, directory, name, body)
|
||||||
|
}
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Imports: true}, testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{},
|
||||||
|
)
|
||||||
|
if test.conflict {
|
||||||
|
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||||
|
t.Fatalf("relative import composition = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
loadError := err.(*LoadError)
|
||||||
|
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.wantPath {
|
||||||
|
t.Fatalf("conflict path = %q, want %q", diagnostics[0].Path.String(), test.wantPath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("relative import composition: %v", err)
|
||||||
|
}
|
||||||
|
configuration := testLoaded(loaded).Config
|
||||||
|
for path, want := range test.want {
|
||||||
|
parsed, err := ParsePath(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys := make([]string, len(parsed.segments))
|
||||||
|
for index, segment := range parsed.segments {
|
||||||
|
keys[index] = segment.key
|
||||||
|
}
|
||||||
|
if got := layeredMapValue(t, configuration, keys...); got != want {
|
||||||
|
t.Errorf("value at %s = %#v, want %#v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("upper tier cannot repair a lower import conflict", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
lowerPath := writeImportFile(t, directory, "lower.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: left.yml
|
||||||
|
destination: [application]
|
||||||
|
- path: right.yml
|
||||||
|
destination: [application]
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "left.yml", "value: left\n")
|
||||||
|
writeImportFile(t, directory, "right.yml", "value: right\n")
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: Bytes("upper", []byte("application: {value: upper}\n"))},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{Source: File(lowerPath), Imports: true}}}}},
|
||||||
|
)
|
||||||
|
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||||
|
t.Fatalf("upper tier repaired a lower conflict: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeImportDestinationsUseEffectiveVersionOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
rulePath Path
|
||||||
|
fragment string
|
||||||
|
}{
|
||||||
|
{name: "direct claim", rulePath: Keys("collection", "group", "nested", "version"), fragment: "version: v2\n"},
|
||||||
|
{name: "ancestor claim", rulePath: Keys("collection", "group", "nested", "schema", "version"), fragment: "schema: null\n"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: parent.yml
|
||||||
|
destination: [group]
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: fragment.yml
|
||||||
|
destination: [nested]
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||||
|
_, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
|
||||||
|
Path: test.rulePath, Check: func(Version, *Validation) error { return nil },
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
if groupedErrorCode(t, err) != CodeImportVersion {
|
||||||
|
t.Fatalf("effective imported claim error = %v", err)
|
||||||
|
}
|
||||||
|
loadError := err.(*LoadError)
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Path.String() != test.rulePath.String() || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-3" {
|
||||||
|
t.Fatalf("effective version diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
wantDestinations := []string{"/collection", "/collection/group", "/collection/group/nested"}
|
||||||
|
if len(sources) != len(wantDestinations) {
|
||||||
|
t.Fatalf("version failure sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for index, source := range sources {
|
||||||
|
if source.Destination.String() != wantDestinations[index] {
|
||||||
|
t.Errorf("source %d destination = %q, want %q", index, source.Destination.String(), wantDestinations[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeImportDestinationsKeepMainVersionCheckScoped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Collection struct {
|
||||||
|
SchemaVersion string `yaml:"schema_version"`
|
||||||
|
Group map[string]any `yaml:"group"`
|
||||||
|
} `yaml:"collection"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: parent.yml
|
||||||
|
destination: [group]
|
||||||
|
schema_version: 6
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: fragment.yml
|
||||||
|
destination: [nested]
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "fragment.yml", "version: 7\n")
|
||||||
|
checks := 0
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
|
||||||
|
testSecretBinding(),
|
||||||
|
LayeredOptions[config]{Definition: Definition[config]{Version: &VersionRule{
|
||||||
|
Path: Keys("collection", "schema_version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
checks++
|
||||||
|
if !version.Present || version.Text != "6" {
|
||||||
|
t.Errorf("main-owned version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ordinary nested imported version was rejected: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if checks != 1 || result.Config.Collection.SchemaVersion != "6" {
|
||||||
|
t.Fatalf("main version callback/value = %d/%q", checks, result.Config.Collection.SchemaVersion)
|
||||||
|
}
|
||||||
|
nested, ok := result.Config.Collection.Group["nested"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("nested imported payload = %#v", result.Config.Collection.Group["nested"])
|
||||||
|
}
|
||||||
|
if got, ok := nested["version"].(int64); !ok || got != 7 {
|
||||||
|
t.Fatalf("ordinary nested version = %#v, want integer payload 7", nested["version"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
|
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -189,7 +532,7 @@ func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
|||||||
|
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "shared.yml", "value: shared\n")
|
writeImportFile(t, directory, "shared.yml", "value: shared\n")
|
||||||
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml}]}\n")
|
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml, destination: [nested]}]}\n")
|
||||||
_, err := LoadLayered(
|
_, err := LoadLayered(
|
||||||
Input{Source: NoSource()},
|
Input{Source: NoSource()},
|
||||||
testSecretBinding(),
|
testSecretBinding(),
|
||||||
@@ -205,4 +548,27 @@ func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
|||||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
|
||||||
t.Fatalf("load error = %#v", err)
|
t.Fatalf("load error = %#v", err)
|
||||||
}
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 3 || sources[1].Destination.String() != "/imported/nested" || sources[2].Destination.String() != "/direct" {
|
||||||
|
t.Fatalf("repeated import sources = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("placement does not break a physical cycle", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: parent.yml, destination: [one]}]}\n")
|
||||||
|
writeImportFile(t, directory, "parent.yml", "configkit: {imports: [{path: main.yml, destination: [two]}]}\n")
|
||||||
|
_, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, testSecretBinding(), LayeredOptions[map[string]any]{})
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportCycle {
|
||||||
|
t.Fatalf("cycle load error = %#v", err)
|
||||||
|
}
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
|
||||||
|
t.Fatalf("cycle primary location = %#v", diagnostic.Primary)
|
||||||
|
}
|
||||||
|
if sources := loadError.Sources(); len(sources) != 2 || sources[1].Destination.String() != "/one" {
|
||||||
|
t.Fatalf("cycle sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
type destinationDecodeProbe string
|
||||||
|
|
||||||
var destinationDecodeProbeValues []string
|
var destinationDecodeProbeValues []string
|
||||||
|
|||||||
@@ -12,6 +12,98 @@ type destinationLoadConfig struct {
|
|||||||
Value string `yaml:"value"`
|
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) {
|
func TestLayeredLoadPlacesNonImportMainAndUnderlayInputs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
@@ -39,6 +40,57 @@ func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := &document.Node{
|
||||||
|
Kind: document.Mapping,
|
||||||
|
Mapping: []document.Entry{{
|
||||||
|
Key: "name", Value: &document.Node{Kind: document.String, Text: "entry"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
destination := Keys("catalog", "entry")
|
||||||
|
path, zero, err := placeDocument(body, destination, body.Location, 5, 32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path.String() != explicitPath.String() || !reflect.DeepEqual(zero, explicit) {
|
||||||
|
t.Fatalf("zero placement (%q, %#v) differs from mapping mode (%q, %#v)", path.String(), zero, explicitPath.String(), explicit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendDestinationCopiesLiteralSegments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parent := Keys("collection", "entry")
|
||||||
|
relative := Keys("a/b", "0", "~")
|
||||||
|
combined := appendDestination(parent, relative)
|
||||||
|
if got, want := combined.String(), "/collection/entry/a~1b/0/~0"; got != want {
|
||||||
|
t.Fatalf("combined destination = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if len(combined.segments) != 5 {
|
||||||
|
t.Fatalf("combined segments = %#v", combined.segments)
|
||||||
|
}
|
||||||
|
for index, segment := range combined.segments {
|
||||||
|
if segment.kind != pathSegmentKey {
|
||||||
|
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
combined.segments[0].key = "changed parent"
|
||||||
|
combined.segments[2].key = "changed relative"
|
||||||
|
if got := parent.String(); got != "/collection/entry" {
|
||||||
|
t.Fatalf("combined path shares parent segment storage: %q", got)
|
||||||
|
}
|
||||||
|
if got := relative.String(); got != "/a~1b/0/~0" {
|
||||||
|
t.Fatalf("combined path shares relative segment storage: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
|
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -176,3 +228,60 @@ func TestPlaceDocumentRootAndBounds(t *testing.T) {
|
|||||||
t.Fatalf("absent body should not consume placement limits: node %p, error %v", absent, err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type diagnosticAccumulator struct {
|
type diagnosticAccumulator struct {
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
sources []SourceInfo
|
sources []SourceInfo
|
||||||
|
versionState *sourceVersionState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||||
@@ -74,9 +75,13 @@ func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *diagnosticAccumulator) loadError() *LoadError {
|
func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||||
|
sources := a.sources
|
||||||
|
if a.versionState != nil {
|
||||||
|
sources = sourceInfosWithVersionEvidence(sources, a.versionState.evidence, nil, true)
|
||||||
|
}
|
||||||
return &LoadError{
|
return &LoadError{
|
||||||
diagnostics: canonicalDiagnostics(a.diagnostics),
|
diagnostics: canonicalDiagnostics(a.diagnostics),
|
||||||
sources: cloneSourceInfos(a.sources),
|
sources: cloneSourceInfos(sources),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import (
|
|||||||
const directiveKey = "configkit"
|
const directiveKey = "configkit"
|
||||||
|
|
||||||
type importDeclaration struct {
|
type importDeclaration struct {
|
||||||
path string
|
path string
|
||||||
glob string
|
glob string
|
||||||
optional bool
|
destination Path
|
||||||
location Location
|
placement placementMode
|
||||||
|
placementPath Path
|
||||||
|
placementLocation *Location
|
||||||
|
optional bool
|
||||||
|
location Location
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
||||||
@@ -69,7 +73,7 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
|
|||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
||||||
}
|
}
|
||||||
declaration := importDeclaration{location: publicLocation(node.Location)}
|
declaration := importDeclaration{location: publicLocation(node.Location), placementPath: declarationPath.clone()}
|
||||||
seenTarget := false
|
seenTarget := false
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
fieldPath := declarationPath.Key(entry.Key)
|
fieldPath := declarationPath.Key(entry.Key)
|
||||||
@@ -90,6 +94,42 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
|
|||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
||||||
}
|
}
|
||||||
declaration.optional = entry.Value.Text == "true"
|
declaration.optional = entry.Value.Text == "true"
|
||||||
|
case "destination":
|
||||||
|
if entry.Value.Kind != document.Sequence {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", entry.Value)}
|
||||||
|
}
|
||||||
|
relative := Path{segments: make([]pathSegment, 0, len(entry.Value.Sequence))}
|
||||||
|
for index, item := range entry.Value.Sequence {
|
||||||
|
memberPath := fieldPath.Index(index)
|
||||||
|
if item.Kind != document.String {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)}
|
||||||
|
}
|
||||||
|
member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}}
|
||||||
|
if _, err := normalizeDestination(member); err != nil {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)}
|
||||||
|
}
|
||||||
|
relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text})
|
||||||
|
}
|
||||||
|
normalized, err := normalizeDestination(relative)
|
||||||
|
if err != nil {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", entry.Value)}
|
||||||
|
}
|
||||||
|
declaration.destination = normalized
|
||||||
|
case "placement":
|
||||||
|
declaration.placementPath = fieldPath.clone()
|
||||||
|
placementLocation := publicLocation(entry.Value.Location)
|
||||||
|
declaration.placementLocation = &placementLocation
|
||||||
|
if entry.Value.Kind != document.String {
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
||||||
|
}
|
||||||
|
switch entry.Value.Text {
|
||||||
|
case "mapping":
|
||||||
|
declaration.placement = placementModeMapping
|
||||||
|
case "sequence-item":
|
||||||
|
declaration.placement = placementModeSequenceItem
|
||||||
|
default:
|
||||||
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||||
}
|
}
|
||||||
@@ -123,26 +163,54 @@ func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
||||||
|
return fragmentVersionClaimProjected(root, rule, -1, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fragmentVersionClaimProjected(root *document.Node, rule *VersionRule, sourceIndex int, ranges []sequenceRange) *document.Node {
|
||||||
|
return fragmentVersionClaimWithIndex(root, rule, sourceIndex, indexSequenceRanges(ranges))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fragmentVersionClaimWithIndex(root *document.Node, rule *VersionRule, sourceIndex int, ranges sequenceRangeIndex) *document.Node {
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
current := root
|
current := root
|
||||||
|
currentPath := Path{}
|
||||||
if len(rule.Path.segments) == 0 {
|
if len(rule.Path.segments) == 0 {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
for _, segment := range rule.Path.segments {
|
for _, segment := range rule.Path.segments {
|
||||||
if current.Kind != document.Mapping {
|
switch current.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
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
|
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
|
return current
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
|
|||||||
// Replay reconciles with canonical decoding before domain resolution so both
|
// Replay reconciles with canonical decoding before domain resolution so both
|
||||||
// contracts agree on the effective result without retaining a tree per field.
|
// contracts agree on the effective result without retaining a tree per field.
|
||||||
type historyApplication struct {
|
type historyApplication struct {
|
||||||
layer Layer
|
layer Layer
|
||||||
path Path
|
path Path
|
||||||
value *document.Node
|
value *document.Node
|
||||||
origin Origin
|
origin Origin
|
||||||
|
sequences []sequenceHistoryContribution
|
||||||
|
sequencesByPath map[string]sequenceHistoryContribution
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
||||||
@@ -59,33 +61,115 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
|
|||||||
for index, value := range values {
|
for index, value := range values {
|
||||||
cloned[index] = historyApplication{
|
cloned[index] = historyApplication{
|
||||||
layer: value.layer, path: value.path.clone(), value: value.value,
|
layer: value.layer, path: value.path.clone(), value: value.value,
|
||||||
origin: cloneOrigin(value.origin),
|
origin: cloneOrigin(value.origin), sequences: cloneSequenceHistoryContributions(value.sequences),
|
||||||
}
|
}
|
||||||
|
cloned[index].sequencesByPath = indexSequenceHistoryContributions(cloned[index].sequences)
|
||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walkHistoryApplication visits a retained source tree with both its local
|
||||||
|
// semantic path and its effective projected path. Sequence fragments keep
|
||||||
|
// their original tree indexes, while the effective path uses the assigned
|
||||||
|
// range recorded by strict composition.
|
||||||
|
func walkHistoryApplication(application historyApplication, visit func(local, effective Path, node *document.Node)) {
|
||||||
|
if application.value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var walk func(*document.Node, Path, Path)
|
||||||
|
walk = func(node *document.Node, local, effective Path) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visit(local, appendDestination(application.path, effective), node)
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
walk(entry.Value, local.Key(entry.Key), effective.Key(entry.Key))
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
contribution, projected := sequenceHistoryAt(application.sequencesByPath, local)
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
effectiveIndex := index
|
||||||
|
if projected {
|
||||||
|
effectiveIndex += contribution.offset
|
||||||
|
}
|
||||||
|
walk(child, local.Index(index), effective.Index(effectiveIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(application.value, Path{}, Path{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyApplicationLocalPath translates an effective projected path back to
|
||||||
|
// the retained source tree, including indexes shifted by enclosing appends.
|
||||||
|
func historyApplicationLocalPath(application historyApplication, effective Path) (Path, bool) {
|
||||||
|
relative, ok := relativeHistoryPath(application.path, effective)
|
||||||
|
if !ok {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
local := Path{}
|
||||||
|
for _, segment := range relative.segments {
|
||||||
|
if contribution, found := sequenceHistoryAt(application.sequencesByPath, local); found {
|
||||||
|
index, valid := schemaIndex(segment)
|
||||||
|
if !valid || segment.kind == pathSegmentKey || index < contribution.offset || index >= contribution.offset+contribution.length {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
local = local.Index(index - contribution.offset)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
local.segments = append(local.segments, segment)
|
||||||
|
}
|
||||||
|
return local, true
|
||||||
|
}
|
||||||
|
|
||||||
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
|
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
|
||||||
if !application.path.valid() || application.value == nil {
|
if !application.path.valid() || application.value == nil {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
if application.path.String() != "" {
|
if !pathContains(application.path, query) {
|
||||||
if pathsIntersect(application.path, query) {
|
if pathContains(query, application.path) {
|
||||||
return application.path.clone(), true
|
return application.path.clone(), true
|
||||||
}
|
}
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
if len(query.segments) == 0 {
|
sequencePathMatched := false
|
||||||
return Path{}, true
|
for _, contribution := range application.sequences {
|
||||||
|
container := appendDestination(application.path, contribution.path)
|
||||||
|
if !pathContains(container, query) && !pathContains(query, container) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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++ {
|
if sequencePathMatched {
|
||||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
return Path{}, false
|
||||||
|
}
|
||||||
|
if len(query.segments) == len(application.path.segments) || application.value.Kind != document.Mapping {
|
||||||
|
return application.path.clone(), true
|
||||||
|
}
|
||||||
|
relative, _ := relativeHistoryPath(application.path, query)
|
||||||
|
for length := 1; length <= len(relative.segments); length++ {
|
||||||
|
prefix := Path{segments: clonePathSegments(relative.segments[:length], 0)}
|
||||||
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
||||||
if !present {
|
if !present {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
if length == len(relative.segments) || node.Kind != document.Mapping {
|
||||||
return prefix, true
|
return appendDestination(application.path, prefix), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
@@ -96,7 +180,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
|
|||||||
return cloneOrigin(application.origin)
|
return cloneOrigin(application.origin)
|
||||||
}
|
}
|
||||||
origin := Origin{Layer: application.layer}
|
origin := Origin{Layer: application.layer}
|
||||||
relative, ok := relativeHistoryPath(application.path, operationPath)
|
relative, ok := historyApplicationLocalPath(application, operationPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
@@ -119,13 +203,17 @@ func pathsIntersect(left, right Path) bool {
|
|||||||
|
|
||||||
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
|
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
|
||||||
if application.path.String() == "" {
|
if application.path.String() == "" {
|
||||||
mergeHistoryDocument(root, application.value, index)
|
mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequencesByPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mergeHistoryAtPath(root, application.path, application.value, index)
|
mergeHistoryAtPath(root, application.path, application.value, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
|
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
|
||||||
|
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContribution) {
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -141,10 +229,56 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node, index
|
|||||||
index.append(*target, cloned)
|
index.append(*target, cloned)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index)
|
entryPath := path.Key(entry.Key)
|
||||||
|
if contribution, found := sequenceHistoryAt(sequences, entryPath); found {
|
||||||
|
applySequenceHistory(&(*target).Mapping[position].Value, entry.Value, contribution)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mergeHistoryDocumentAtPath(&(*target).Mapping[position].Value, entry.Value, entryPath, index, sequences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sequenceHistoryAt(contributions map[string]sequenceHistoryContribution, path Path) (sequenceHistoryContribution, bool) {
|
||||||
|
contribution, found := contributions[path.String()]
|
||||||
|
return contribution, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexSequenceHistoryContributions(values []sequenceHistoryContribution) map[string]sequenceHistoryContribution {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index := make(map[string]sequenceHistoryContribution, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
index[value.path.String()] = value
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySequenceHistory(target **document.Node, incoming *document.Node, contribution sequenceHistoryContribution) {
|
||||||
|
if incoming == nil || incoming.Kind != document.Sequence {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if contribution.mode == sequenceHistoryReplace || *target == nil || (*target).Kind != document.Sequence {
|
||||||
|
*target = document.Clone(incoming)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range incoming.Sequence {
|
||||||
|
(*target).Sequence = append((*target).Sequence, document.Clone(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]sequenceHistoryContribution, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
cloned[index] = value
|
||||||
|
cloned[index].path = value.path.clone()
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
|
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
|
||||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||||
mergeHistoryDocument(root, incoming, index)
|
mergeHistoryDocument(root, incoming, index)
|
||||||
@@ -152,7 +286,19 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
|
|||||||
}
|
}
|
||||||
current := *root
|
current := *root
|
||||||
for segmentIndex, segment := range path.segments {
|
for segmentIndex, segment := range path.segments {
|
||||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
if segment.kind == pathSegmentIndex {
|
||||||
|
position, ok := schemaIndex(segment)
|
||||||
|
if current.Kind != document.Sequence || !ok || position >= len(current.Sequence) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if segmentIndex == len(path.segments)-1 {
|
||||||
|
mergeHistoryDocument(¤t.Sequence[position], incoming, index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = current.Sequence[position]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current.Kind != document.Mapping {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
position := index.find(current, segment.key)
|
position := index.find(current, segment.key)
|
||||||
|
|||||||
240
internal/engine/history_sequence_test.go
Normal file
240
internal/engine/history_sequence_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -45,17 +45,20 @@ type importAccounting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type importTraversal struct {
|
type importTraversal struct {
|
||||||
access *filesystemAccess
|
access *filesystemAccess
|
||||||
accounting *importAccounting
|
accounting *importAccounting
|
||||||
limits importLimits
|
limits importLimits
|
||||||
rule *VersionRule
|
rule *VersionRule
|
||||||
destination Path
|
versionState *sourceVersionState
|
||||||
|
|
||||||
documents []*document.Node
|
scope acquisitionScope
|
||||||
sources []SourceInfo
|
itemScope bool
|
||||||
active map[resourceIdentity]int
|
itemPrefix Path
|
||||||
visited map[resourceIdentity]importRoute
|
itemFallback Path
|
||||||
stack []importRoute
|
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) {
|
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
@@ -63,12 +66,24 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedDocumentsTrackedWithVersion(source, context, main, destination, rule, limits, accounting, visited, hostToken, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
var processing *sourceVersionProcessingResult
|
||||||
|
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||||
|
processing = versionState.mainProcessing
|
||||||
|
}
|
||||||
|
return loadImportedDocumentsTrackedWithProcessing(source, context, main, destination, rule, limits, accounting, visited, hostToken, versionState, processing, sequencePolicy{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedDocumentsTrackedWithProcessing(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
if nativeContext(source, context) {
|
if nativeContext(source, context) {
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
|
||||||
}
|
}
|
||||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -83,7 +98,7 @@ func loadImportedDocumentsTracked(source Source, context *FilesystemContext, mai
|
|||||||
if hostToken != nil {
|
if hostToken != nil {
|
||||||
access.token = hostToken
|
access.token = hostToken
|
||||||
}
|
}
|
||||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
@@ -91,6 +106,22 @@ func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedWithAccessTrackedWithVersion(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccessTrackedWithVersion(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
var processing *sourceVersionProcessingResult
|
||||||
|
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||||
|
processing = versionState.mainProcessing
|
||||||
|
}
|
||||||
|
return loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, sequencePolicy{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
||||||
_ = access.close()
|
_ = access.close()
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
||||||
@@ -104,6 +135,7 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
|||||||
}
|
}
|
||||||
mainInfo := cloneSourceInfo(*main.source)
|
mainInfo := cloneSourceInfo(*main.source)
|
||||||
mainInfo.Destination = destination.clone()
|
mainInfo.Destination = destination.clone()
|
||||||
|
mainIsItem := main.source.placementMode == placementModeSequenceItem
|
||||||
if mainPath != "" {
|
if mainPath != "" {
|
||||||
mainInfo.Path = mainPath
|
mainInfo.Path = mainPath
|
||||||
}
|
}
|
||||||
@@ -119,11 +151,17 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
|||||||
accounting.bytes += main.bytes
|
accounting.bytes += main.bytes
|
||||||
accounting.nodes += countInputNodes(main.document)
|
accounting.nodes += countInputNodes(main.document)
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
access: access, accounting: accounting, limits: limits, rule: rule,
|
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
|
||||||
destination: destination.clone(),
|
scope: acquisitionScope{destination: destination.clone()},
|
||||||
documents: make([]*document.Node, 0, 1),
|
sources: []SourceInfo{mainInfo},
|
||||||
sources: []SourceInfo{mainInfo},
|
active: make(map[resourceIdentity]int), visited: visited,
|
||||||
active: make(map[resourceIdentity]int), visited: visited,
|
}
|
||||||
|
if mainIsItem {
|
||||||
|
state.itemScope = true
|
||||||
|
state.itemPrefix = destination.clone()
|
||||||
|
state.itemFallback = destination.clone()
|
||||||
|
state.scope.destination = Path{}
|
||||||
|
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||||
}
|
}
|
||||||
if state.visited == nil {
|
if state.visited == nil {
|
||||||
state.visited = make(map[resourceIdentity]importRoute)
|
state.visited = make(map[resourceIdentity]importRoute)
|
||||||
@@ -141,30 +179,118 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
|||||||
state.active[mainKey] = 0
|
state.active[mainKey] = 0
|
||||||
state.visited[mainKey] = mainRoute
|
state.visited[mainKey] = mainRoute
|
||||||
state.stack = append(state.stack, mainRoute)
|
state.stack = append(state.stack, mainRoute)
|
||||||
body, declarations, diagnostics := splitImportDirectives(main.document)
|
var body *document.Node
|
||||||
|
var declarations []importDeclaration
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
if processing != nil {
|
||||||
|
body = processing.payload
|
||||||
|
declarations = processing.imports
|
||||||
|
} else {
|
||||||
|
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||||
|
}
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
if mainIsItem {
|
||||||
placementNodes := destinationWrapperNodeCount(state.destination)
|
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||||
if err != nil {
|
source: cloneSourceInfo(mainInfo), payload: body, document: body, processing: processing,
|
||||||
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 = state.walkDeclarations(basePath, declarations, 0, Path{})
|
||||||
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
|
||||||
} else {
|
} else {
|
||||||
accounting.nodes += placementNodes
|
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
state.documents = append(state.documents, placed)
|
placementNodes := destinationWrapperNodeCount(destination)
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
if err != nil {
|
||||||
|
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||||
|
} else if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||||
|
} else {
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
||||||
|
destination: destination.clone(), processing: processing,
|
||||||
|
})
|
||||||
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeErr := access.close()
|
closeErr := access.close()
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
|
if mainIsItem {
|
||||||
|
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
|
||||||
|
}
|
||||||
return nil, cloneSourceInfos(state.sources), diagnostics
|
return nil, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
}
|
}
|
||||||
composed, diagnostics := composeDocuments(state.documents...)
|
if mainIsItem {
|
||||||
|
localScope := state.scope
|
||||||
|
documents := make([]*document.Node, 0, len(localScope.contributions))
|
||||||
|
for _, contribution := range localScope.contributions {
|
||||||
|
documents = append(documents, contribution.document)
|
||||||
|
}
|
||||||
|
completed, failures := composeDocuments(documents...)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return nil, cloneSourceInfos(state.sources), prependCompositionDiagnosticPaths(failures, destination.Index(0))
|
||||||
|
}
|
||||||
|
if versionState != nil {
|
||||||
|
protectionRule := versionState.localRule
|
||||||
|
if versionState.mode == sourceVersionModeDefault && protectionRule != nil {
|
||||||
|
prefix := destination.Index(0)
|
||||||
|
relative, inside := relativeHistoryPath(prefix, protectionRule.Path)
|
||||||
|
if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) {
|
||||||
|
protectionRule = nil
|
||||||
|
} else {
|
||||||
|
scoped := *protectionRule
|
||||||
|
scoped.Path = relative
|
||||||
|
protectionRule = &scoped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic := importedVersionClaim(documents, completed, protectionRule, 0); diagnostic != nil {
|
||||||
|
if rule != nil {
|
||||||
|
diagnostic.Path = rule.Path.clone()
|
||||||
|
}
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
|
||||||
|
projectNestedEvidence(completed, localScope)
|
||||||
|
_, placed, err := placeDocumentWithMode(completed.root, destination, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(destination, placementModeSequenceItem)
|
||||||
|
if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||||
|
}
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
composed, failures := composeDocumentsWithPolicy(policy, placed)
|
||||||
|
if composed != nil {
|
||||||
|
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 composed != nil {
|
||||||
|
if diagnostic := importedVersionClaim(documents, composed, rule, 0); diagnostic != nil {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if composed != nil {
|
||||||
|
projectNestedEvidence(composed, state.scope)
|
||||||
|
projectAcquiredDestinations(composed, state.scope, state.sources)
|
||||||
|
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)}
|
||||||
composed.inputBytes = accounting.bytes
|
composed.inputBytes = accounting.bytes
|
||||||
composed.inputNodes = accounting.nodes
|
composed.inputNodes = accounting.nodes
|
||||||
}
|
}
|
||||||
@@ -187,8 +313,9 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int, parentDestination Path) []Diagnostic {
|
||||||
for _, declaration := range declarations {
|
for _, declaration := range declarations {
|
||||||
|
childDestination := appendDestination(parentDestination, declaration.destination)
|
||||||
if declaration.glob != "" {
|
if declaration.glob != "" {
|
||||||
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -205,21 +332,29 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
|
|||||||
matched := declaration
|
matched := declaration
|
||||||
matched.path = match
|
matched.path = match
|
||||||
matched.glob = ""
|
matched.glob = ""
|
||||||
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
|
if diagnostics := s.visitPath(basePath, matched, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
|
if diagnostics := s.visitPath(basePath, declaration, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
|
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||||
location := declaration.location
|
location := declaration.location
|
||||||
|
if declaration.placement == placementModeSequenceItem && len(destination.segments) == 0 {
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeImportInvalid, Severity: SeverityError,
|
||||||
|
Path: declaration.placementPath.clone(),
|
||||||
|
Message: "sequence-item placement requires a nonempty destination",
|
||||||
|
Primary: cloneLocation(declaration.placementLocation),
|
||||||
|
}}
|
||||||
|
}
|
||||||
if depth > s.limits.depth {
|
if depth > s.limits.depth {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||||
}
|
}
|
||||||
@@ -272,7 +407,14 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
kind = SourceKindFilesystem
|
kind = SourceKindFilesystem
|
||||||
label = s.access.label + "/" + canonical
|
label = s.access.label + "/" + canonical
|
||||||
}
|
}
|
||||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.destination.clone(), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||||
|
if declaration.placement == placementModeSequenceItem {
|
||||||
|
info.placementMode = placementModeSequenceItem
|
||||||
|
}
|
||||||
|
// A source's own item placement still has an exact sequence-container
|
||||||
|
// destination. Only an unresolved enclosing item index makes the path a
|
||||||
|
// fallback container for this snapshot.
|
||||||
|
info.destinationUnresolved = s.itemScope
|
||||||
if kind == SourceKindFilesystem {
|
if kind == SourceKindFilesystem {
|
||||||
info.namespace = s.access.token
|
info.namespace = s.access.token
|
||||||
}
|
}
|
||||||
@@ -299,38 +441,339 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
}
|
}
|
||||||
s.accounting.nodes += nodeCount
|
s.accounting.nodes += nodeCount
|
||||||
|
|
||||||
body, declarations, diagnostics := splitImportDirectives(root)
|
var body *document.Node
|
||||||
if len(diagnostics) != 0 {
|
var declarations []importDeclaration
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
var processing *sourceVersionProcessingResult
|
||||||
|
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
|
||||||
|
versionDestination := destination
|
||||||
|
if s.itemScope {
|
||||||
|
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
|
||||||
|
}
|
||||||
|
processed, failures := s.versionState.process(root, sourceVersionContribution, versionDestination, sourceID, declaration.placement)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
processing = &processed
|
||||||
|
body, declarations = processed.payload, processed.imports
|
||||||
|
} else {
|
||||||
|
body, declarations, diagnostics = splitImportDirectives(root)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placementDestination := destination
|
||||||
|
mode := placementModeMapping
|
||||||
|
if declaration.placement == placementModeSequenceItem {
|
||||||
|
mode = placementModeSequenceItem
|
||||||
|
}
|
||||||
|
if mode == placementModeSequenceItem {
|
||||||
|
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
||||||
|
s.active[identity] = len(s.stack)
|
||||||
|
s.visited[identity] = route
|
||||||
|
s.stack = append(s.stack, route)
|
||||||
|
diagnostics := s.visitItemSource(canonical, declaration, destination, relativeDestination, depth, info, body, declarations, processing)
|
||||||
|
delete(s.active, identity)
|
||||||
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
_, placed, err := placeDocument(body, s.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
_, placed, err := placeDocument(body, placementDestination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
|
||||||
}
|
}
|
||||||
placementNodes := destinationWrapperNodeCount(s.destination)
|
placementNodes := destinationWrapperNodeCount(placementDestination)
|
||||||
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
}
|
}
|
||||||
s.accounting.nodes += placementNodes
|
s.accounting.nodes += placementNodes
|
||||||
if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil {
|
|
||||||
versionLocation := publicLocation(versionNode.Location)
|
|
||||||
return []Diagnostic{{
|
|
||||||
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
|
||||||
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
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.active[identity] = len(s.stack)
|
||||||
s.visited[identity] = route
|
s.visited[identity] = route
|
||||||
s.stack = append(s.stack, route)
|
s.stack = append(s.stack, route)
|
||||||
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth)
|
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth, destination)
|
||||||
delete(s.active, identity)
|
delete(s.active, identity)
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) sourceDestination(destination Path) Path {
|
||||||
|
if !s.itemScope {
|
||||||
|
return destination.clone()
|
||||||
|
}
|
||||||
|
// Until the enclosing item receives its assigned index, only the collection
|
||||||
|
// container is exact. Keep the local suffix in the acquisition projection.
|
||||||
|
return s.itemFallback.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) visitItemSource(canonical string, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic {
|
||||||
|
parentScope := s.scope
|
||||||
|
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
||||||
|
s.scope = acquisitionScope{}
|
||||||
|
s.itemScope = true
|
||||||
|
if parentItemScope {
|
||||||
|
s.itemPrefix = appendDestination(parentPrefix, destination)
|
||||||
|
} else {
|
||||||
|
s.itemPrefix = destination.clone()
|
||||||
|
s.itemFallback = destination.clone()
|
||||||
|
}
|
||||||
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(info), payload: body, document: body,
|
||||||
|
processing: processing,
|
||||||
|
})
|
||||||
|
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
|
||||||
|
itemScope := s.scope
|
||||||
|
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
|
||||||
|
}
|
||||||
|
documents := make([]*document.Node, 0, len(itemScope.contributions))
|
||||||
|
for _, contribution := range itemScope.contributions {
|
||||||
|
documents = append(documents, contribution.document)
|
||||||
|
}
|
||||||
|
completed, diagnostics := composeDocuments(documents...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
|
||||||
|
}
|
||||||
|
itemScope.destination = Path{}
|
||||||
|
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(itemScope)}
|
||||||
|
projectNestedEvidence(completed, itemScope)
|
||||||
|
pl := destination
|
||||||
|
_, placed, err := placeDocumentWithMode(completed.root, pl, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &declaration.location)}
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(pl, placementModeSequenceItem)
|
||||||
|
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &declaration.location)}
|
||||||
|
}
|
||||||
|
s.accounting.nodes += placementNodes
|
||||||
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
|
source: cloneSourceInfo(info), payload: completed.root, document: placed,
|
||||||
|
destination: pl.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
||||||
|
nested: completed,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedVersionClaim(documents []*document.Node, composed *composedDocument, rule *VersionRule, mainIndex int) *Diagnostic {
|
||||||
|
if composed == nil || rule == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||||
|
for index, contribution := range documents {
|
||||||
|
if index == mainIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node := fragmentVersionClaimWithIndex(contribution, rule, index, ranges); node != nil {
|
||||||
|
location := publicLocation(node.Location)
|
||||||
|
return &Diagnostic{
|
||||||
|
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prependCompositionDiagnosticPaths(diagnostics []Diagnostic, prefix Path) []Diagnostic {
|
||||||
|
cloned := append([]Diagnostic(nil), diagnostics...)
|
||||||
|
for index := range cloned {
|
||||||
|
if !isCompositionDiagnostic(cloned[index]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cloned[index].Path = appendDestination(prefix, cloned[index].Path)
|
||||||
|
cloned[index].Primary = cloneLocation(cloned[index].Primary)
|
||||||
|
cloned[index].Related = cloneLocations(cloned[index].Related)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCompositionDiagnostic(diagnostic Diagnostic) bool {
|
||||||
|
return diagnostic.Code == CodeComposeConflict || diagnostic.Code == CodeComposeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a composition failure only the earlier contributions are needed to locate
|
||||||
|
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
|
||||||
|
// and access diagnostics keep their original source-local coordinates.
|
||||||
|
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path) []Diagnostic {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if isCompositionDiagnostic(diagnostic) {
|
||||||
|
index := priorSequenceLength([]acquisitionScope{parent}, destination)
|
||||||
|
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
// This scan is confined to failed loads, not the successful construction path.
|
||||||
|
func priorSequenceLength(scopes []acquisitionScope, path Path) int {
|
||||||
|
length := 0
|
||||||
|
for _, scope := range scopes {
|
||||||
|
for _, contribution := range scope.contributions {
|
||||||
|
if node, present := documentAtPath(contribution.document, path); present && node.Kind == document.Sequence {
|
||||||
|
length += len(node.Sequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectPriorInputDiagnosticPaths(diagnostics []Diagnostic, scopes []acquisitionScope) []Diagnostic {
|
||||||
|
cloned := cloneDiagnostics(diagnostics)
|
||||||
|
for index := range cloned {
|
||||||
|
if !isCompositionDiagnostic(cloned[index]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for position, segment := range cloned[index].Path.segments {
|
||||||
|
if segment.kind != pathSegmentIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
container := Path{segments: cloned[index].Path.segments[:position]}
|
||||||
|
cloned[index].Path.segments[position].index += priorSequenceLength(scopes, container)
|
||||||
|
// Registration does not address paths through item indexes. A prior
|
||||||
|
// input shifts the outer collection, not its item-local sequences.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
|
||||||
|
if composed == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applications := composed.applications
|
||||||
|
projectedApplications := make([]historyApplication, 0, len(applications))
|
||||||
|
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||||
|
for sourceIndex, contribution := range scope.contributions {
|
||||||
|
if contribution.nested == nil {
|
||||||
|
projectedApplications = append(projectedApplications, applications[sourceIndex])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}].start
|
||||||
|
prefix := contribution.destination.Index(start)
|
||||||
|
if sourceIndex < len(applications) {
|
||||||
|
outer := applications[sourceIndex]
|
||||||
|
structural := document.Clone(outer.value)
|
||||||
|
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > 0 {
|
||||||
|
// The retained fragment is still a local singleton. Its assigned
|
||||||
|
// outer index belongs to the projection, not this source tree.
|
||||||
|
node.Sequence[0] = &document.Node{Kind: document.Mapping, Location: node.Sequence[0].Location}
|
||||||
|
}
|
||||||
|
outer.value = structural
|
||||||
|
projectedApplications = append(projectedApplications, outer)
|
||||||
|
for _, nested := range contribution.nested.applications {
|
||||||
|
projected := nested
|
||||||
|
projected.path = appendDestination(prefix, nested.path)
|
||||||
|
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
|
||||||
|
projected.sequencesByPath = indexSequenceHistoryContributions(projected.sequences)
|
||||||
|
projectedApplications = append(projectedApplications, projected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
|
||||||
|
}
|
||||||
|
composed.applications = projectedApplications
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
|
||||||
|
var visit func(*document.Node, Path)
|
||||||
|
visit = func(node *document.Node, path Path) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if origin, ok := origins[path.String()]; ok {
|
||||||
|
target[appendDestination(prefix, path).String()] = cloneOrigin(origin)
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
visit(entry.Value, path.Key(entry.Key))
|
||||||
|
}
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
visit(child, path.Index(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(root, Path{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectAcquiredDestinations(composed *composedDocument, scope acquisitionScope, sources []SourceInfo) {
|
||||||
|
byID := make(map[string]int, len(sources))
|
||||||
|
for index := range sources {
|
||||||
|
byID[sources[index].ID] = index
|
||||||
|
}
|
||||||
|
var projectScope func(*composedDocument, acquisitionScope, Path, bool, string)
|
||||||
|
projectScope = func(currentComposition *composedDocument, current acquisitionScope, prefix Path, insideItem bool, parentSourceID string) {
|
||||||
|
ranges := indexSequenceRanges(currentComposition.sequenceRanges)
|
||||||
|
for sourceIndex, contribution := range current.contributions {
|
||||||
|
if contribution.source.ID == parentSourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contributionPrefix := prefix
|
||||||
|
contributionInsideItem := insideItem
|
||||||
|
start := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}].start
|
||||||
|
if contribution.source.placementMode == placementModeSequenceItem {
|
||||||
|
container := appendDestination(contributionPrefix, contribution.destination)
|
||||||
|
contribution.source.Destination = container.clone()
|
||||||
|
contribution.source.destinationUnresolved = false
|
||||||
|
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
|
||||||
|
if position, ok := byID[contribution.source.ID]; ok {
|
||||||
|
sources[position].Destination = container
|
||||||
|
sources[position].destinationUnresolved = false
|
||||||
|
}
|
||||||
|
contributionPrefix = appendDestination(container, Path{}.Index(start))
|
||||||
|
contributionInsideItem = true
|
||||||
|
projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex)
|
||||||
|
} else if contributionInsideItem {
|
||||||
|
exactDestination := appendDestination(contributionPrefix, contribution.destination)
|
||||||
|
contribution.source.Destination = exactDestination.clone()
|
||||||
|
contribution.source.destinationUnresolved = false
|
||||||
|
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
|
||||||
|
if position, ok := byID[contribution.source.ID]; ok {
|
||||||
|
sources[position].Destination = exactDestination
|
||||||
|
sources[position].destinationUnresolved = false
|
||||||
|
}
|
||||||
|
projectSourceVersionEvidence(contribution.processing, exactDestination, nil, sourceIndex)
|
||||||
|
} else {
|
||||||
|
projectSourceVersionEvidence(contribution.processing, contribution.destination, ranges, sourceIndex)
|
||||||
|
}
|
||||||
|
if contribution.nested != nil && contribution.nested.acquisition != nil {
|
||||||
|
for _, nestedScope := range contribution.nested.acquisition {
|
||||||
|
projectScope(contribution.nested, nestedScope, contributionPrefix, contributionInsideItem, contribution.source.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectScope(composed, scope, Path{}, false, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path, ranges sequenceRangeIndex, sourceIndex int) {
|
||||||
|
if processing != nil && processing.evidence != nil {
|
||||||
|
evidence := processing.evidence
|
||||||
|
evidence.destination = destination.clone()
|
||||||
|
// Rebuild from source-local paths on each handoff: an input can be
|
||||||
|
// composed first on its own and then at a different offset in its tier.
|
||||||
|
evidence.projectedPaths = nil
|
||||||
|
paths := evidence.sensitivityPaths()
|
||||||
|
for index, path := range paths {
|
||||||
|
for position := range path.segments {
|
||||||
|
prefix := Path{segments: path.segments[:position]}
|
||||||
|
assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: prefix.String()}]
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, ok := schemaIndex(path.segments[position])
|
||||||
|
if ok && path.segments[position].kind != pathSegmentKey {
|
||||||
|
path.segments[position] = pathSegment{kind: pathSegmentIndex, index: assigned.start + item}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths[index] = path
|
||||||
|
}
|
||||||
|
evidence.projectedPaths = paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
||||||
remaining := s.limits.bytes - s.accounting.bytes
|
remaining := s.limits.bytes - s.accounting.bytes
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
type importTestConfig struct {
|
type importTestConfig struct {
|
||||||
@@ -155,6 +157,8 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||||
|
{name: "placement kind", body: "configkit: {imports: [{path: a.yml, placement: true}]}\n", code: CodeImportInvalid},
|
||||||
|
{name: "placement value", body: "configkit: {imports: [{path: a.yml, placement: item}]}\n", code: CodeImportInvalid},
|
||||||
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -166,6 +170,434 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadItemImportComposesHelpersAndNestedItemsInDeclaringScope(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type nested struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type record struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
Metadata map[string]string `yaml:"metadata"`
|
||||||
|
Children []nested `yaml:"children"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []record `yaml:"items"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: records/item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
itemPath := writeImportFile(t, directory, "records/item.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: nested/helper.yml
|
||||||
|
- path: child.yml
|
||||||
|
destination: [children]
|
||||||
|
placement: sequence-item
|
||||||
|
name: authored
|
||||||
|
`)
|
||||||
|
helperPath := writeImportFile(t, directory, "records/nested/helper.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: more.yml
|
||||||
|
destination: [metadata]
|
||||||
|
id: supplied
|
||||||
|
label: helper
|
||||||
|
`)
|
||||||
|
morePath := writeImportFile(t, directory, "records/nested/more.yml", "flavor: transitive\n")
|
||||||
|
childPath := writeImportFile(t, directory, "records/child.yml", "value: nested\n")
|
||||||
|
|
||||||
|
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load item import: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 1 {
|
||||||
|
t.Fatalf("items = %#v", result.Config.Items)
|
||||||
|
}
|
||||||
|
item := result.Config.Items[0]
|
||||||
|
if item.ID != "supplied" || item.Name != "authored" || item.Label != "helper" || item.Metadata["flavor"] != "transitive" || len(item.Children) != 1 || item.Children[0].Value != "nested" {
|
||||||
|
t.Fatalf("composed item = %#v", item)
|
||||||
|
}
|
||||||
|
if got := result.Sources(); len(got) != 5 || got[1].Path != canonicalTestPath(t, itemPath) || got[2].Path != canonicalTestPath(t, helperPath) || got[3].Path != canonicalTestPath(t, morePath) || got[4].Path != canonicalTestPath(t, childPath) {
|
||||||
|
t.Fatalf("source traversal = %#v", got)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
index int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{index: 1, want: "/items"},
|
||||||
|
{index: 2, want: "/items/0"},
|
||||||
|
{index: 3, want: "/items/0/metadata"},
|
||||||
|
{index: 4, want: "/items/0/children"},
|
||||||
|
} {
|
||||||
|
if got := result.Sources()[test.index].Destination.String(); got != test.want {
|
||||||
|
t.Errorf("source %d destination = %q, want %q", test.index+1, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path Path
|
||||||
|
sourceID string
|
||||||
|
}{
|
||||||
|
{path: Keys("items").Index(0).Key("id"), sourceID: "source-3"},
|
||||||
|
{path: Keys("items").Index(0).Key("metadata").Key("flavor"), sourceID: "source-4"},
|
||||||
|
} {
|
||||||
|
info, ok := result.Metadata().Lookup(test.path)
|
||||||
|
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != test.sourceID {
|
||||||
|
t.Errorf("origin at %s = %#v, %v, want source %s", test.path.String(), info, ok, test.sourceID)
|
||||||
|
}
|
||||||
|
foundInHistory := false
|
||||||
|
for _, change := range result.Metadata().History(test.path) {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
foundInHistory = foundInHistory || location.SourceID == test.sourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundInHistory {
|
||||||
|
t.Errorf("history at %s does not retain source %s", test.path.String(), test.sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("duplicate helper identity remains a mapping conflict", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "item.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: helper.yml
|
||||||
|
id: authored
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "helper.yml", "id: supplied\n")
|
||||||
|
_, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("duplicate identity error = %v", err)
|
||||||
|
}
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || len(diagnostic.Related) != 1 {
|
||||||
|
t.Fatalf("duplicate identity diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredUnderlayItemImportRetainsNestedEvidenceAndDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type nested struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type record struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
Metadata map[string]string `yaml:"metadata"`
|
||||||
|
Children []nested `yaml:"children"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Catalog struct {
|
||||||
|
Entries []record `yaml:"entries"`
|
||||||
|
} `yaml:"catalog"`
|
||||||
|
}
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
itemPath := writeImportFile(t, directory, "records/item.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: nested/helper.yml
|
||||||
|
- path: children/child.yml
|
||||||
|
destination: [children]
|
||||||
|
placement: sequence-item
|
||||||
|
name: authored
|
||||||
|
`)
|
||||||
|
helperPath := writeImportFile(t, directory, "records/nested/helper.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: more.yml
|
||||||
|
destination: [metadata]
|
||||||
|
id: supplied
|
||||||
|
label: helper
|
||||||
|
`)
|
||||||
|
morePath := writeImportFile(t, directory, "records/nested/more.yml", "flavor: transitive\n")
|
||||||
|
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
||||||
|
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: NoSource()}, testSecretBinding(),
|
||||||
|
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*LoadError); ok {
|
||||||
|
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0].ID != "supplied" || result.Config.Catalog.Entries[0].Name != "authored" ||
|
||||||
|
result.Config.Catalog.Entries[0].Label != "helper" || result.Config.Catalog.Entries[0].Metadata["flavor"] != "transitive" ||
|
||||||
|
len(result.Config.Catalog.Entries[0].Children) != 1 || result.Config.Catalog.Entries[0].Children[0].Value != "nested" {
|
||||||
|
t.Fatalf("underlay item = %#v", result.Config.Catalog.Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := result.Sources()
|
||||||
|
wantSources := []struct {
|
||||||
|
path string
|
||||||
|
destination string
|
||||||
|
}{
|
||||||
|
{path: itemPath, destination: "/catalog/entries"},
|
||||||
|
{path: helperPath, destination: "/catalog/entries/0"},
|
||||||
|
{path: morePath, destination: "/catalog/entries/0/metadata"},
|
||||||
|
{path: childPath, destination: "/catalog/entries/0/children"},
|
||||||
|
}
|
||||||
|
if len(sources) != len(wantSources) {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for index, want := range wantSources {
|
||||||
|
if sources[index].Path != canonicalTestPath(t, want.path) || sources[index].Destination.String() != want.destination {
|
||||||
|
t.Errorf("source %d = %#v, want path %q and destination %q", index+1, sources[index], want.path, want.destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
path Path
|
||||||
|
sourceID string
|
||||||
|
}{
|
||||||
|
{path: Keys("catalog", "entries").Index(0).Key("id"), sourceID: "source-2"},
|
||||||
|
{path: Keys("catalog", "entries").Index(0).Key("metadata").Key("flavor"), sourceID: "source-3"},
|
||||||
|
{path: Keys("catalog", "entries").Index(0).Key("children").Index(0).Key("value"), sourceID: "source-4"},
|
||||||
|
} {
|
||||||
|
info, ok := result.Metadata().Lookup(test.path)
|
||||||
|
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != test.sourceID || info.Origin.Layer != LayerUnderlay {
|
||||||
|
t.Errorf("origin at %s = %#v, %v, want %s in underlay", test.path.String(), info, ok, test.sourceID)
|
||||||
|
}
|
||||||
|
foundInHistory := false
|
||||||
|
for _, change := range result.Metadata().History(test.path) {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
foundInHistory = foundInHistory || location.SourceID == test.sourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundInHistory {
|
||||||
|
t.Errorf("history at %s does not retain source %s", test.path.String(), test.sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type item struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
Other map[string]string `yaml:"other"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- glob: records/*.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
- path: absent/*.yml
|
||||||
|
destination: [items]
|
||||||
|
optional: true
|
||||||
|
placement: sequence-item
|
||||||
|
- path: other.yml
|
||||||
|
destination: [other]
|
||||||
|
`)
|
||||||
|
emptyPath := writeImportFile(t, directory, "records/empty.yml", "{}\n")
|
||||||
|
writeImportFile(t, directory, "other.yml", "value: mapping-default\n")
|
||||||
|
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load item glob: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 1 || result.Config.Items[0].Value != "" || result.Config.Other["value"] != "mapping-default" {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != placementModeSequenceItem || sources[2].placementMode != placementModeMapping || sources[2].Destination.String() != "/other" {
|
||||||
|
t.Fatalf("source placement evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
|
||||||
|
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
|
||||||
|
result, err := testLoad[config](File(mainPath), Options[config]{
|
||||||
|
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load main item: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 1 || result.Config.Items[0].ID != "main" || result.Config.Items[0].Value != "helper" {
|
||||||
|
t.Fatalf("main item = %#v", result.Config.Items)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != placementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != placementModeMapping {
|
||||||
|
t.Fatalf("main item sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if sources[1].Path != canonicalTestPath(t, helperPath) {
|
||||||
|
t.Fatalf("helper path = %q", sources[1].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]string `yaml:"items"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
|
||||||
|
writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
|
||||||
|
input, diagnostics := assembleBaseInput(File(mainPath), Options[config]{Imports: true})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("assemble item import: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
|
||||||
|
composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy{identityFields: map[string]string{"/items": "id"}}, authored, input.document)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
items, ok := documentAtPath(composed.root, Keys("items"))
|
||||||
|
if !ok || items.Kind != document.Sequence || len(items.Sequence) != 2 {
|
||||||
|
t.Fatalf("composed items = %#v", items)
|
||||||
|
}
|
||||||
|
if id, ok := documentAtPath(composed.root, Keys("items").Index(1).Key("id")); !ok || id.Text != "helper-id" {
|
||||||
|
t.Fatalf("imported helper ID = %#v, %v", id, ok)
|
||||||
|
}
|
||||||
|
if origin := composed.origins[Keys("items").Index(1).Key("id").String()]; len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-3" {
|
||||||
|
t.Fatalf("registered composition origin = %#v", origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
destination string
|
||||||
|
want Path
|
||||||
|
}{
|
||||||
|
{name: "omitted"},
|
||||||
|
{name: "empty sequence", destination: " destination: []\n"},
|
||||||
|
{
|
||||||
|
name: "literal keys",
|
||||||
|
destination: " destination: ['', '.', '..', 'a/b', '~', '0', configkit2, myextends]\n",
|
||||||
|
want: Keys("", ".", "..", "a/b", "~", "0", "configkit2", "myextends"),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
body := "configkit:\n imports:\n - path: child.yml\n" + test.destination
|
||||||
|
declarations := parseImportDeclarationsYAML(t, body)
|
||||||
|
if len(declarations) != 1 {
|
||||||
|
t.Fatalf("declarations = %#v", declarations)
|
||||||
|
}
|
||||||
|
got := declarations[0].destination
|
||||||
|
if got.String() != test.want.String() {
|
||||||
|
t.Fatalf("destination = %q, want %q", got.String(), test.want.String())
|
||||||
|
}
|
||||||
|
for index, segment := range got.segments {
|
||||||
|
if segment.kind != pathSegmentKey {
|
||||||
|
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
declarations := parseImportDeclarationsYAML(t, `configkit:
|
||||||
|
imports:
|
||||||
|
- path: mapping.yml
|
||||||
|
- glob: records/*.yml
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
if len(declarations) != 2 || declarations[0].placement != placementModeMapping || declarations[1].placement != placementModeSequenceItem {
|
||||||
|
t.Fatalf("declaration placements = %#v", declarations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
wantPath string
|
||||||
|
badMember string
|
||||||
|
}{
|
||||||
|
{name: "null", value: "null", wantPath: "/configkit/imports/0/destination", badMember: "null"},
|
||||||
|
{name: "scalar string", value: "prompts", wantPath: "/configkit/imports/0/destination", badMember: "prompts"},
|
||||||
|
{name: "number", value: "3", wantPath: "/configkit/imports/0/destination", badMember: "3"},
|
||||||
|
{name: "non-string number", value: "[valid, 7]", wantPath: "/configkit/imports/0/destination/1", badMember: "7"},
|
||||||
|
{name: "non-string null", value: "[valid, null]", wantPath: "/configkit/imports/0/destination/1", badMember: "null"},
|
||||||
|
{name: "non-string boolean", value: "[valid, true]", wantPath: "/configkit/imports/0/destination/1", badMember: "true"},
|
||||||
|
{name: "non-string mapping", value: "[valid, {key: value}]", wantPath: "/configkit/imports/0/destination/1", badMember: "{key"},
|
||||||
|
{name: "reserved configkit", value: "[valid, configkit]", wantPath: "/configkit/imports/0/destination/1", badMember: "configkit"},
|
||||||
|
{name: "reserved extends", value: "[valid, extends]", wantPath: "/configkit/imports/0/destination/1", badMember: "extends"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n"
|
||||||
|
parsed, diagnostics := parseImportDeclarationYAML(body)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath {
|
||||||
|
t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath)
|
||||||
|
}
|
||||||
|
line := strings.Split(body, "\n")[3]
|
||||||
|
wantColumn := strings.Index(line, test.badMember) + 1
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" || diagnostics[0].Primary.Line != 4 || diagnostics[0].Primary.Column != wantColumn {
|
||||||
|
t.Fatalf("invalid destination location = %#v, want line 4 column %d (parsed %v)", diagnostics[0].Primary, wantColumn, parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDestinationValidationPrecedesOptionalSuppression(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "optional missing path",
|
||||||
|
body: "configkit:\n imports:\n - path: absent.yml\n optional: true\n destination: null\n",
|
||||||
|
path: "/configkit/imports/0/destination",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional unmatched glob",
|
||||||
|
body: "configkit:\n imports:\n - glob: absent/*.yml\n optional: true\n destination: [extends]\n",
|
||||||
|
path: "/configkit/imports/0/destination/0",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", test.body)
|
||||||
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||||
|
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.path {
|
||||||
|
t.Fatalf("diagnostic path = %q, want %q", diagnostics[0].Path.String(), test.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||||
t.Run("optional missing", func(t *testing.T) {
|
t.Run("optional missing", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
@@ -486,3 +918,21 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
|
|||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseImportDeclarationsYAML(t *testing.T, input string) []importDeclaration {
|
||||||
|
t.Helper()
|
||||||
|
declarations, diagnostics := parseImportDeclarationYAML(input)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("import parse diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return declarations
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportDeclarationYAML(input string) ([]importDeclaration, []Diagnostic) {
|
||||||
|
parsed, diagnostics := parseSource(Bytes("import fixture", []byte(input)), "source-1", defaultParseLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
_, declarations, diagnostics := splitImportDirectives(parsed.document)
|
||||||
|
return declarations, diagnostics
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,40 @@ func TestInheritanceSensitivityIncludesSchemaTaggedSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSourceVersionEvidenceSensitivityFollowsInheritanceAndDisplacedHistory(t *testing.T) {
|
||||||
|
const canary = "EVIDENCE-INHERITANCE-CANARY"
|
||||||
|
input := parseCompositionDocument(t, "source-1", "parent: {branch: {token: "+canary+", plain: visible}}\nchild: {extends: parent, branch: public}\n")
|
||||||
|
inspection, _ := inheritanceInspectionForTest(t, input)
|
||||||
|
secretPath := Keys("parent", "branch", "token")
|
||||||
|
evidence := &sourceVersionEvidence{
|
||||||
|
path: secretPath.clone(), resolvedPath: secretPath.clone(),
|
||||||
|
version: Version{Present: true, Text: canary},
|
||||||
|
ancestors: []sourceVersionAncestor{{}, {path: Keys("parent")}, {path: Keys("parent", "branch")}, {path: secretPath.clone()}},
|
||||||
|
}
|
||||||
|
inspection.secrets = classifySecretPathsWithEvidence(nil, func(path Path) bool {
|
||||||
|
return path.String() == secretPath.String()
|
||||||
|
}, inspection.metadata, inspection.final, inspection.applications, []*sourceVersionEvidence{evidence}, inspection.input)
|
||||||
|
|
||||||
|
path := Keys("child", "branch", "token")
|
||||||
|
explanation, found := inspection.Explain(path)
|
||||||
|
if !found || explanation.Effective.Present {
|
||||||
|
t.Fatalf("displaced inherited path = %#v, %v", explanation, found)
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) ||
|
||||||
|
(change.After.Present && !change.After.Redacted) {
|
||||||
|
t.Fatalf("displaced inherited canary escaped redaction: %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plain, found := inspection.Explain(Keys("parent", "branch", "plain"))
|
||||||
|
if !found || plain.Effective.Redacted || plain.Effective.YAML != "visible\n" {
|
||||||
|
t.Fatalf("neighboring value was classified with evidence: %#v, %v", plain, found)
|
||||||
|
}
|
||||||
|
if _, found := inspection.Metadata().Lookup(Keys("evidence-only")); found {
|
||||||
|
t.Fatal("evidence classification created a metadata path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) {
|
func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) {
|
||||||
type entry struct {
|
type entry struct {
|
||||||
Credential Secret `yaml:"credential"`
|
Credential Secret `yaml:"credential"`
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import "os"
|
|||||||
|
|
||||||
// Input selects one source, its destination, and its independent import policy.
|
// Input selects one source, its destination, and its independent import policy.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
Source Source
|
Source Source
|
||||||
Imports bool
|
Imports bool
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Destination Path
|
Destination Path
|
||||||
|
placementMode placementMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Underlay contains strictly composed contributions at one precedence tier.
|
// Underlay contains strictly composed contributions at one precedence tier.
|
||||||
@@ -16,6 +17,7 @@ type Underlay struct{ Inputs []Input }
|
|||||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||||
type LayeredOptions[T any] struct {
|
type LayeredOptions[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
|
SourceVersionMode sourceVersionMode
|
||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
@@ -35,9 +37,10 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
|||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{
|
main: loadInput{
|
||||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
||||||
destination: main.Destination.clone(),
|
destination: main.Destination.clone(), placementMode: main.placementMode,
|
||||||
},
|
},
|
||||||
definition: options.Definition, overlay: options.Overlay,
|
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||||
|
overlay: options.Overlay,
|
||||||
overlayFilesystem: options.OverlayFilesystem,
|
overlayFilesystem: options.OverlayFilesystem,
|
||||||
overrides: append([]Override(nil), options.Overrides...),
|
overrides: append([]Override(nil), options.Overrides...),
|
||||||
underlays: make([]loadGroup, len(options.Underlays)),
|
underlays: make([]loadGroup, len(options.Underlays)),
|
||||||
@@ -47,7 +50,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
|||||||
for inputIndex, input := range underlay.Inputs {
|
for inputIndex, input := range underlay.Inputs {
|
||||||
request.underlays[index].inputs[inputIndex] = loadInput{
|
request.underlays[index].inputs[inputIndex] = loadInput{
|
||||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
||||||
destination: input.Destination.clone(),
|
destination: input.Destination.clone(), placementMode: input.placementMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,23 @@ type Definition[T any] struct {
|
|||||||
Validate func(*T, *Validation)
|
Validate func(*T, *Validation)
|
||||||
Version *VersionRule
|
Version *VersionRule
|
||||||
Secret func(Path) bool
|
Secret func(Path) bool
|
||||||
|
|
||||||
|
sequenceRegistrations []sequenceRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
Overlay string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
|
SourceVersionMode sourceVersionMode
|
||||||
|
destination Path
|
||||||
|
placementMode placementMode
|
||||||
|
sequencePolicy sequencePolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||||
@@ -136,13 +142,19 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
|
|||||||
|
|
||||||
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
|
policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations)
|
||||||
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
||||||
}
|
}
|
||||||
|
definition := options.Definition
|
||||||
|
definition.sequenceRegistrations = nil
|
||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem},
|
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
||||||
definition: options.Definition, overlay: options.Overlay,
|
definition: definition, sequencePolicy: policy, overlay: options.Overlay,
|
||||||
overrides: options.Overrides, ordinary: true,
|
overrides: options.Overrides, ordinary: true,
|
||||||
|
sourceVersionMode: options.SourceVersionMode,
|
||||||
|
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
|
||||||
}
|
}
|
||||||
return executeLoadRequest(request, secrets, lookup)
|
return executeLoadRequest(request, secrets, lookup)
|
||||||
}
|
}
|
||||||
@@ -150,32 +162,65 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
|
|||||||
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem}
|
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
|
||||||
|
mainOptions.sequencePolicy = request.sequencePolicy
|
||||||
|
mainOptions.SourceVersionMode = request.sourceVersionMode
|
||||||
|
mainOptions.Definition.Version = request.protectionVersion()
|
||||||
|
mainOptions.Overrides = request.overrides
|
||||||
if request.ordinary {
|
if request.ordinary {
|
||||||
mainOptions.Overlay = request.overlay
|
mainOptions.Overlay = request.overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, request.main.destination, accounting, nil, nil)
|
var schema *schemaDescriptor
|
||||||
|
if request.sourceVersionMode != sourceVersionModeDefault {
|
||||||
|
var schemaIssue *schemaIssue
|
||||||
|
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
|
||||||
|
if schemaIssue != nil {
|
||||||
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
versionState := &sourceVersionState{
|
||||||
|
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
||||||
|
}
|
||||||
|
accumulator.versionState = versionState
|
||||||
|
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||||
accumulator.sources = input.sources
|
accumulator.sources = input.sources
|
||||||
|
accumulator.add(versionState.diagnostics...)
|
||||||
|
reportedVersionDiagnostics := len(versionState.diagnostics)
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
|
if schema == nil {
|
||||||
if schemaIssue != nil {
|
var schemaIssue *schemaIssue
|
||||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
|
||||||
return nil, accumulator.loadError()
|
if schemaIssue != nil {
|
||||||
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
versionDiagnostics := checkVersion(request.definition.Version, input.document)
|
if request.sourceVersionMode == sourceVersionModeDefault {
|
||||||
accumulator.add(versionDiagnostics...)
|
versionDiagnostics := checkVersion(request.definition.Version, input.document)
|
||||||
if hasErrors(versionDiagnostics) {
|
accumulator.add(versionDiagnostics...)
|
||||||
return nil, accumulator.loadError()
|
if hasErrors(versionDiagnostics) {
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.ordinary && input.document != nil {
|
||||||
|
input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
|
return nil, accumulator.loadError()
|
||||||
|
}
|
||||||
|
input.document = input.composition.root
|
||||||
}
|
}
|
||||||
if request.ordinary {
|
if request.ordinary {
|
||||||
input, diagnostics = applyInputLayers(request.main.source, Options[T]{Definition: request.definition, Overlay: request.overlay, Filesystem: request.main.filesystem, Overrides: request.overrides}, input)
|
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
|
||||||
} else {
|
} else {
|
||||||
input, diagnostics = assembleLayeredInput(request, input, accounting)
|
input, diagnostics = assembleLayeredInput(request, input, accounting)
|
||||||
}
|
}
|
||||||
|
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
|
||||||
accumulator.sources = input.sources
|
accumulator.sources = input.sources
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -208,7 +253,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
accumulator.add(problems...)
|
accumulator.add(problems...)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil {
|
if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil {
|
||||||
accumulator.add(*problem)
|
accumulator.add(*problem)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
@@ -217,7 +262,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
input.document = expanded.root
|
input.document = expanded.root
|
||||||
}
|
}
|
||||||
|
|
||||||
decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
|
decodeDefinition := request.definition
|
||||||
|
decodeDefinition.Version = request.protectionVersion()
|
||||||
|
decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input)
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
|
|||||||
@@ -8,12 +8,61 @@ import (
|
|||||||
|
|
||||||
// assembledInput is the source/document handoff across schema and version checks.
|
// assembledInput is the source/document handoff across schema and version checks.
|
||||||
type assembledInput struct {
|
type assembledInput struct {
|
||||||
parsed parsedSource
|
parsed parsedSource
|
||||||
document *document.Node
|
document *document.Node
|
||||||
composition *composedDocument
|
composition *composedDocument
|
||||||
inheritance *inheritanceScope
|
acquisition []acquisitionScope
|
||||||
sources []SourceInfo
|
inheritance *inheritanceScope
|
||||||
contexts map[string]ResourceContext
|
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) {
|
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
||||||
@@ -21,14 +70,25 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
||||||
|
return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (assembledInput, []Diagnostic) {
|
||||||
|
return assembleBaseInputTrackedForRole(source, options, destination, accounting, visited, hostToken, versionState, sourceVersionMain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
parseLimits := defaultParseLimits()
|
parseLimits := defaultParseLimits()
|
||||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||||
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
||||||
input := assembledInput{parsed: parsed, document: parsed.document}
|
if parsed.source != nil {
|
||||||
|
parsed.source.Destination = destination.clone()
|
||||||
|
parsed.source.placementMode = options.placementMode
|
||||||
|
}
|
||||||
|
input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
input.sources = sourceInfos(parsed.source)
|
input.sources = sourceInfos(parsed.source)
|
||||||
setSourceDestinations(input.sources, destination)
|
|
||||||
// Mapping value issues already identify application content. Other early
|
// Mapping value issues already identify application content. Other early
|
||||||
// failures describe source syntax or access, not a placed value.
|
// failures describe source syntax or access, not a placed value.
|
||||||
if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
|
if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
|
||||||
@@ -40,16 +100,38 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
|
|||||||
}
|
}
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
|
if !options.Imports && parsed.document != nil {
|
||||||
|
if diagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}); len(diagnostics) != 0 {
|
||||||
|
return input, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var versionProcessing *sourceVersionProcessingResult
|
||||||
|
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||||
|
var sourceID string
|
||||||
|
if parsed.source != nil {
|
||||||
|
sourceID = parsed.source.ID
|
||||||
|
}
|
||||||
|
processed, failures := versionState.process(parsed.document, role, destination, sourceID, options.placementMode)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return input, failures
|
||||||
|
}
|
||||||
|
versionProcessing = &processed
|
||||||
|
if role == sourceVersionMain {
|
||||||
|
versionState.mainProcessing = &processed
|
||||||
|
}
|
||||||
|
}
|
||||||
if options.Imports {
|
if options.Imports {
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken)
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
||||||
setSourceDestinations(input.sources, destination)
|
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
|
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||||
input.document = input.composition.root
|
input.document = input.composition.root
|
||||||
} else {
|
} else {
|
||||||
input.sources = sourceInfos(parsed.source)
|
input.sources = sourceInfos(parsed.source)
|
||||||
setSourceDestinations(input.sources, destination)
|
if versionProcessing != nil {
|
||||||
|
input.document = versionProcessing.payload
|
||||||
|
}
|
||||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
@@ -57,16 +139,21 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !options.Imports && input.document != nil {
|
if !options.Imports && input.document != nil {
|
||||||
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
|
_, placed, err := placeDocumentWithMode(input.document, destination, options.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
|
||||||
return input, diagnostics
|
|
||||||
}
|
|
||||||
_, placed, err := placeDocument(input.document, destination, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
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.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)
|
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 {
|
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||||
}
|
}
|
||||||
@@ -109,15 +196,15 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
|
|||||||
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
|
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
composition, diagnostics := compositionForInput(input)
|
composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
assembly := newAssemblyAccumulator(composition)
|
assembly := newAssemblyAccumulator(composition)
|
||||||
if activeOverlay(options.Overlay) {
|
if activeOverlay(options.Overlay) {
|
||||||
input.sources, diagnostics = loadSelectedOverlayInto(
|
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
|
||||||
source, options.Filesystem, input.parsed, assembly, input.sources,
|
source, options.Filesystem, input.parsed, assembly, input.sources,
|
||||||
options.Overlay, options.Definition.Version, defaultImportLimits(),
|
options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState,
|
||||||
)
|
)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
@@ -144,17 +231,22 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
|
|||||||
// compositionForInput initializes one handoff for overlays and CLI, including
|
// compositionForInput initializes one handoff for overlays and CLI, including
|
||||||
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
||||||
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
||||||
|
return compositionForInputWithPolicy(input, sequencePolicy{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func compositionForInputWithPolicy(input assembledInput, policy sequencePolicy) (*composedDocument, []Diagnostic) {
|
||||||
if input.composition != nil {
|
if input.composition != nil {
|
||||||
return input.composition, nil
|
return input.composition, nil
|
||||||
}
|
}
|
||||||
if input.document == nil {
|
if input.document == nil {
|
||||||
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
|
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
|
||||||
}
|
}
|
||||||
composition, diagnostics := composeDocuments(input.document)
|
composition, diagnostics := composeDocumentsWithPolicy(policy, input.document)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, diagnostics
|
return nil, diagnostics
|
||||||
}
|
}
|
||||||
composition.inputBytes = input.parsed.bytes
|
composition.inputBytes = input.parsed.bytes
|
||||||
composition.inputNodes = countInputNodes(input.document)
|
composition.inputNodes = countInputNodes(input.document)
|
||||||
|
composition.acquisition = cloneAcquisitionScopes(input.acquisition)
|
||||||
return composition, nil
|
return composition, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,28 @@ type loadRequest[T any] struct {
|
|||||||
main loadInput
|
main loadInput
|
||||||
underlays []loadGroup
|
underlays []loadGroup
|
||||||
definition Definition[T]
|
definition Definition[T]
|
||||||
|
sequencePolicy sequencePolicy
|
||||||
|
sourceVersionMode sourceVersionMode
|
||||||
|
effectiveVersion *VersionRule
|
||||||
overlay string
|
overlay string
|
||||||
overlayFilesystem *FilesystemContext
|
overlayFilesystem *FilesystemContext
|
||||||
overrides []Override
|
overrides []Override
|
||||||
ordinary bool
|
ordinary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (request loadRequest[T]) protectionVersion() *VersionRule {
|
||||||
|
if request.effectiveVersion != nil {
|
||||||
|
return request.effectiveVersion
|
||||||
|
}
|
||||||
|
return request.definition.Version
|
||||||
|
}
|
||||||
|
|
||||||
type loadInput struct {
|
type loadInput struct {
|
||||||
source Source
|
source Source
|
||||||
imports bool
|
imports bool
|
||||||
filesystem *FilesystemContext
|
filesystem *FilesystemContext
|
||||||
destination Path
|
destination Path
|
||||||
|
placementMode placementMode
|
||||||
}
|
}
|
||||||
|
|
||||||
type loadGroup struct{ inputs []loadInput }
|
type loadGroup struct{ inputs []loadInput }
|
||||||
@@ -28,24 +39,22 @@ type loadGroup struct{ inputs []loadInput }
|
|||||||
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
||||||
request.ordinary = false
|
request.ordinary = false
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
if normalized, err := normalizeDestination(request.main.destination); err != nil {
|
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
|
||||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(request.main.destination))
|
request.sequencePolicy = policy
|
||||||
} else {
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||||
request.main.destination = normalized
|
request.definition.sequenceRegistrations = nil
|
||||||
}
|
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||||
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
||||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem,
|
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
||||||
}})
|
}})
|
||||||
|
request.main.destination = mainOptions.destination
|
||||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||||
|
request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
|
||||||
for groupIndex := range request.underlays {
|
for groupIndex := range request.underlays {
|
||||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||||
if normalized, err := normalizeDestination(input.destination); err != nil {
|
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode}})
|
||||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(input.destination))
|
input.destination = inputOptions.destination
|
||||||
} else {
|
|
||||||
input.destination = normalized
|
|
||||||
}
|
|
||||||
_, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem}})
|
|
||||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +77,7 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
|||||||
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||||
groups := make([]*composedDocument, 0, len(request.underlays))
|
groups := make([]*composedDocument, 0, len(request.underlays))
|
||||||
for index, group := range request.underlays {
|
for index, group := range request.underlays {
|
||||||
composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting)
|
composition, sources, contexts, diagnostics := acquireUnderlayGroupWithPolicyVersion(group, index+1, request.protectionVersion(), accounting, input.versionState, request.sequencePolicy)
|
||||||
input.sources = append(input.sources, sources...)
|
input.sources = append(input.sources, sources...)
|
||||||
mergeResourceContexts(&input.contexts, contexts)
|
mergeResourceContexts(&input.contexts, contexts)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -90,7 +99,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if input.document != nil {
|
if input.document != nil {
|
||||||
mainComposition, diagnostics := compositionForInput(input)
|
mainComposition, diagnostics := compositionForInputWithPolicy(input, request.sequencePolicy)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
@@ -115,7 +124,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
selection := Source{kind: sourceBytes}
|
selection := Source{kind: sourceBytes}
|
||||||
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
|
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.definition.Version, defaultImportLimits())
|
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
@@ -127,7 +136,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
}
|
}
|
||||||
if len(request.overrides) != 0 {
|
if len(request.overrides) != 0 {
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.definition.Version, defaultImportLimits())
|
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.protectionVersion(), defaultImportLimits())
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
@@ -138,15 +147,27 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||||
|
return acquireUnderlayGroupWithVersion(group, index, rule, accounting, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||||
|
return acquireUnderlayGroupWithPolicyVersion(group, index, rule, accounting, versionState, sequencePolicy{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState, policy sequencePolicy) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||||
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
||||||
visited := make(map[resourceIdentity]importRoute)
|
visited := make(map[resourceIdentity]importRoute)
|
||||||
hostToken := &namespaceToken{}
|
hostToken := &namespaceToken{}
|
||||||
var documents []*document.Node
|
var scopes []acquisitionScope
|
||||||
var sources []SourceInfo
|
var sources []SourceInfo
|
||||||
var contexts map[string]ResourceContext
|
var contexts map[string]ResourceContext
|
||||||
for _, contribution := range group.inputs {
|
for _, contribution := range group.inputs {
|
||||||
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}}
|
options := Options[struct{}]{
|
||||||
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, contribution.destination, accounting, visited, hostToken)
|
Imports: contribution.imports, Filesystem: contribution.filesystem,
|
||||||
|
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
|
||||||
|
placementMode: contribution.placementMode, sequencePolicy: policy,
|
||||||
|
}
|
||||||
|
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
|
||||||
for _, source := range acquired.sources {
|
for _, source := range acquired.sources {
|
||||||
source.Layer = LayerUnderlay
|
source.Layer = LayerUnderlay
|
||||||
source.UnderlayIndex = index
|
source.UnderlayIndex = index
|
||||||
@@ -154,35 +175,47 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
|
|||||||
}
|
}
|
||||||
mergeResourceContexts(&contexts, acquired.contexts)
|
mergeResourceContexts(&contexts, acquired.contexts)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, sources, contexts, diagnostics
|
return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
|
||||||
}
|
}
|
||||||
if acquired.document == nil {
|
if acquired.document == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if node := fragmentVersionClaim(acquired.document, rule); node != nil {
|
scopes = append(scopes, cloneAcquisitionScopes(acquired.acquisition)...)
|
||||||
location := publicLocation(node.Location)
|
}
|
||||||
return nil, sources, contexts, []Diagnostic{{
|
var documents []*document.Node
|
||||||
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
for _, scope := range scopes {
|
||||||
Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location,
|
for _, contribution := range scope.contributions {
|
||||||
}}
|
documents = append(documents, contribution.document)
|
||||||
}
|
|
||||||
if acquired.composition != nil {
|
|
||||||
for _, application := range acquired.composition.applications {
|
|
||||||
documents = append(documents, application.value)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
documents = append(documents, acquired.document)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(documents) == 0 {
|
if len(documents) == 0 {
|
||||||
return nil, sources, contexts, nil
|
return nil, sources, contexts, nil
|
||||||
}
|
}
|
||||||
composition, diagnostics := composeDocuments(documents...)
|
composition, diagnostics := composeDocumentsWithPolicy(policy, documents...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, sources, contexts, diagnostics
|
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.inputBytes = accounting.bytes - initialBytes
|
||||||
composition.inputNodes = accounting.nodes - initialNodes
|
composition.inputNodes = accounting.nodes - initialNodes
|
||||||
|
composition.acquisition = cloneAcquisitionScopes(scopes)
|
||||||
|
var projectionScope acquisitionScope
|
||||||
|
for _, scope := range scopes {
|
||||||
|
projectionScope.contributions = append(projectionScope.contributions, scope.contributions...)
|
||||||
|
}
|
||||||
|
projectNestedEvidence(composition, projectionScope)
|
||||||
|
projectAcquiredDestinations(composition, projectionScope, sources)
|
||||||
for pointer, origin := range composition.origins {
|
for pointer, origin := range composition.origins {
|
||||||
origin.Layer = LayerUnderlay
|
origin.Layer = LayerUnderlay
|
||||||
composition.origins[pointer] = origin
|
composition.origins[pointer] = origin
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import (
|
|||||||
// resolvedLoad carries typed phase outputs needed for validation and publication;
|
// resolvedLoad carries typed phase outputs needed for validation and publication;
|
||||||
// it owns no source access, options, or long-lived schema state.
|
// it owns no source access, options, or long-lived schema state.
|
||||||
type resolvedLoad[T any] struct {
|
type resolvedLoad[T any] struct {
|
||||||
configuration T
|
configuration T
|
||||||
decoded decodedConfiguration
|
decoded decodedConfiguration
|
||||||
metadata Metadata
|
metadata Metadata
|
||||||
final *document.Node
|
final *document.Node
|
||||||
snapshots []resolutionSnapshot
|
snapshots []resolutionSnapshot
|
||||||
applications []historyApplication
|
applications []historyApplication
|
||||||
inheritance *inheritanceScope
|
versionEvidence []*sourceVersionEvidence
|
||||||
contexts map[string]ResourceContext
|
inheritance *inheritanceScope
|
||||||
|
contexts map[string]ResourceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
|
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
|
||||||
@@ -57,7 +58,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
final := decoded.final
|
final := decoded.final
|
||||||
var resolutionSnapshots []resolutionSnapshot
|
var resolutionSnapshots []resolutionSnapshot
|
||||||
if definition.Resolve != nil {
|
if definition.Resolve != nil {
|
||||||
entry := newResolutionInput(metadata, decoded.final, input.sources, input.contexts)
|
sources := input.sources
|
||||||
|
if input.versionState != nil {
|
||||||
|
sources = sourceInfosWithVersionEvidence(sources, input.versionState.evidence, nil, true)
|
||||||
|
}
|
||||||
|
entry := newResolutionInput(metadata, decoded.final, sources, input.contexts)
|
||||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
|
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
|
||||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||||
if hasErrors(resolved.diagnostics) {
|
if hasErrors(resolved.diagnostics) {
|
||||||
@@ -73,7 +78,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
} else if input.document != nil {
|
} else if input.document != nil {
|
||||||
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
||||||
}
|
}
|
||||||
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
|
var versionEvidence []*sourceVersionEvidence
|
||||||
|
if input.versionState != nil {
|
||||||
|
versionEvidence = append(versionEvidence, input.versionState.evidence...)
|
||||||
|
}
|
||||||
|
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
||||||
@@ -97,7 +106,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
|||||||
decoded, metadata := resolved.decoded, resolved.metadata
|
decoded, metadata := resolved.decoded, resolved.metadata
|
||||||
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||||
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, decoded.final)
|
||||||
|
sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false)
|
||||||
retainedTrees := make([]*document.Node, 0, len(applications)+2)
|
retainedTrees := make([]*document.Node, 0, len(applications)+2)
|
||||||
retainedTrees = append(retainedTrees, decoded.final, final)
|
retainedTrees = append(retainedTrees, decoded.final, final)
|
||||||
for _, application := range applications {
|
for _, application := range applications {
|
||||||
@@ -105,7 +115,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
|||||||
}
|
}
|
||||||
inspection := &Inspection{
|
inspection := &Inspection{
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
sources: cloneSourceInfos(accumulator.sources),
|
sources: sources,
|
||||||
contexts: cloneResourceContexts(resolved.contexts),
|
contexts: cloneResourceContexts(resolved.contexts),
|
||||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||||
baseline: decoded.baseline,
|
baseline: decoded.baseline,
|
||||||
|
|||||||
@@ -463,6 +463,41 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVersionWarningDoesNotSuppressReturnedError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct{}
|
||||||
|
versionPath := Keys("schema_version")
|
||||||
|
result, err := testLoad(Bytes("version.yml", []byte("{}\n")), Options[config]{
|
||||||
|
Definition: Definition[config]{Version: &VersionRule{
|
||||||
|
Path: versionPath,
|
||||||
|
Check: func(_ Version, validation *Validation) error {
|
||||||
|
validation.Warning(versionPath, "review schema compatibility")
|
||||||
|
return errors.New("CANARY_VERSION_ERROR")
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result = %#v, err = %v", result, err)
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error type = %T", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
var warning, hookFailure bool
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if strings.Contains(diagnostic.Message, "CANARY") {
|
||||||
|
t.Fatalf("returned checker error text was exposed: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
warning = warning || diagnostic.Code == CodeVersionWarning && diagnostic.Severity == SeverityWarning
|
||||||
|
hookFailure = hookFailure || diagnostic.Code == CodeVersionHookFailed && diagnostic.Severity == SeverityError
|
||||||
|
}
|
||||||
|
if !warning || !hookFailure {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
|
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -149,12 +149,9 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
|
|||||||
walkDocument(tree, Path{}, add)
|
walkDocument(tree, Path{}, add)
|
||||||
}
|
}
|
||||||
for _, application := range applications {
|
for _, application := range applications {
|
||||||
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
|
walkHistoryApplication(application, func(_ Path, effective Path, _ *document.Node) {
|
||||||
prefix := Path{}
|
add(effective, nil)
|
||||||
for _, segment := range application.path.segments {
|
})
|
||||||
prefix = prefix.Key(segment.key)
|
|
||||||
}
|
|
||||||
walkDocument(application.value, prefix, add)
|
|
||||||
}
|
}
|
||||||
for _, operation := range m.inheritance {
|
for _, operation := range m.inheritance {
|
||||||
if operation.control != nil {
|
if operation.control != nil {
|
||||||
|
|||||||
@@ -42,6 +42,34 @@ items: {main: main}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNativeFilesystemItemImportUsesSharedScopedAssembly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
filesystem := fstest.MapFS{
|
||||||
|
"main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: records/item.yml\n destination: [items]\n placement: sequence-item\n")},
|
||||||
|
"records/item.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: helper.yml\nid: native\n")},
|
||||||
|
"records/helper.yml": &fstest.MapFile{Data: []byte("name: helper\n")},
|
||||||
|
}
|
||||||
|
namespace := newNativeNamespace("bundle", filesystem)
|
||||||
|
result, err := testLoad[config](filesystemFile(namespace, "main.yml"), Options[config]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("native item import: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 1 || result.Config.Items[0].ID != "native" || result.Config.Items[0].Name != "helper" {
|
||||||
|
t.Fatalf("native item = %#v", result.Config.Items)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[1].Destination.String() != "/items" || sources[2].Destination.String() != "/items/0" || sources[1].Path != "records/item.yml" || sources[2].Path != "records/helper.yml" {
|
||||||
|
t.Fatalf("native item sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) {
|
func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) {
|
||||||
first := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: first}\n")}}
|
first := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: first}\n")}}
|
||||||
second := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: second}\n")}}
|
second := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: second}\n")}}
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
options = supplied[0]
|
options = supplied[0]
|
||||||
}
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
|
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
||||||
|
}
|
||||||
|
if normalized, err := normalizeDestination(options.destination); err != nil {
|
||||||
|
diagnostics = append(diagnostics, invalidDestinationDiagnostic(options.destination))
|
||||||
|
} else {
|
||||||
|
options.destination = normalized
|
||||||
|
}
|
||||||
|
if options.placementMode == placementModeSequenceItem && len(options.destination.segments) == 0 {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination"))
|
||||||
|
}
|
||||||
definition := options.Definition
|
definition := options.Definition
|
||||||
if !validOverlayName(options.Overlay) {
|
if !validOverlayName(options.Overlay) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||||
@@ -33,6 +44,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if options.SourceVersionMode != sourceVersionModeDefault && options.SourceVersionMode != sourceVersionModeIfPresent && options.SourceVersionMode != sourceVersionModeRequired {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid"))
|
||||||
|
} else if options.SourceVersionMode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker"))
|
||||||
|
}
|
||||||
return options, canonicalDiagnostics(diagnostics)
|
return options, canonicalDiagnostics(diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,15 +183,45 @@ type Location struct {
|
|||||||
// namespace-relative filesystem path; synthetic sources have no Path.
|
// namespace-relative filesystem path; synthetic sources have no Path.
|
||||||
// ImportedFrom identifies the declaration that included this source.
|
// ImportedFrom identifies the declaration that included this source.
|
||||||
type SourceInfo struct {
|
type SourceInfo struct {
|
||||||
ID string
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Path string
|
Path string
|
||||||
Destination Path
|
Destination Path
|
||||||
Layer Layer
|
Layer Layer
|
||||||
Kind SourceKind
|
Kind SourceKind
|
||||||
UnderlayIndex int
|
UnderlayIndex int
|
||||||
ImportedFrom *Location
|
ImportedFrom *Location
|
||||||
namespace *namespaceToken
|
VersionCheck *SourceVersionCheck
|
||||||
|
namespace *namespaceToken
|
||||||
|
placementMode placementMode
|
||||||
|
destinationUnresolved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
||||||
|
// root-owned numeric representation.
|
||||||
|
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||||
|
return uint8(source.placementMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoDestinationUnresolvedForFacade returns the private fallback marker.
|
||||||
|
func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool {
|
||||||
|
return source.destinationUnresolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoWithPlacement copies facade-owned source evidence into private fields.
|
||||||
|
func SourceInfoWithPlacement(source SourceInfo, mode uint8, unresolved bool) SourceInfo {
|
||||||
|
source.placementMode = placementMode(mode)
|
||||||
|
source.destinationUnresolved = unresolved
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceVersionCheck is a detached, public-safe record of a source-local
|
||||||
|
// version declaration observed during an enabled load.
|
||||||
|
type SourceVersionCheck struct {
|
||||||
|
Path Path
|
||||||
|
Version Version
|
||||||
|
Location *Location
|
||||||
|
Redacted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic is a safe structured problem report. Message and source labels must
|
// Diagnostic is a safe structured problem report. Message and source labels must
|
||||||
@@ -262,9 +292,20 @@ func cloneLocations(locations []Location) []Location {
|
|||||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||||
source.Destination = source.Destination.clone()
|
source.Destination = source.Destination.clone()
|
||||||
|
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *value
|
||||||
|
cloned.Path = value.Path.clone()
|
||||||
|
cloned.Location = cloneLocation(value.Location)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
||||||
if sources == nil {
|
if sources == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
main string
|
main string
|
||||||
child string
|
child string
|
||||||
|
helper string
|
||||||
failRead string
|
failRead string
|
||||||
options Options[config]
|
options Options[config]
|
||||||
wantErr bool
|
wantErr bool
|
||||||
@@ -150,6 +151,16 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
|
|||||||
{name: "acquisition", main: "value: retained\n", failRead: "main.yml", wantErr: true},
|
{name: "acquisition", main: "value: retained\n", failRead: "main.yml", wantErr: true},
|
||||||
{name: "parse", main: "value: [broken\n", wantErr: true},
|
{name: "parse", main: "value: [broken\n", wantErr: true},
|
||||||
{name: "composition", main: "configkit: {imports: [{path: child.yml}]}\nvalue: first\n", child: "value: second\n", options: Options[config]{Imports: true}, wantErr: true},
|
{name: "composition", main: "configkit: {imports: [{path: child.yml}]}\nvalue: first\n", child: "value: second\n", options: Options[config]{Imports: true}, wantErr: true},
|
||||||
|
{
|
||||||
|
name: "nested item composition",
|
||||||
|
main: "configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n",
|
||||||
|
child: "configkit: {imports: [{path: helper.yml}]}\nid: child\nvalue: child\n",
|
||||||
|
helper: "value: helper\n",
|
||||||
|
options: Options[config]{
|
||||||
|
Imports: true, destination: Keys("groups"), placementMode: placementModeSequenceItem,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
{name: "version", main: "version: wrong\n", options: Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return fs.ErrInvalid }}}}, wantErr: true},
|
{name: "version", main: "version: wrong\n", options: Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return fs.ErrInvalid }}}}, wantErr: true},
|
||||||
{name: "validation", main: "value: retained\n", options: Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) { validation.Error(Keys("value"), "rejected") }}}, wantErr: true},
|
{name: "validation", main: "value: retained\n", options: Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) { validation.Error(Keys("value"), "rejected") }}}, wantErr: true},
|
||||||
} {
|
} {
|
||||||
@@ -158,6 +169,9 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
|
|||||||
if test.child != "" {
|
if test.child != "" {
|
||||||
files["child.yml"] = &fstest.MapFile{Data: []byte(test.child)}
|
files["child.yml"] = &fstest.MapFile{Data: []byte(test.child)}
|
||||||
}
|
}
|
||||||
|
if test.helper != "" {
|
||||||
|
files["helper.yml"] = &fstest.MapFile{Data: []byte(test.helper)}
|
||||||
|
}
|
||||||
filesystem := &observedFilesystem{files: files, failRead: test.failRead}
|
filesystem := &observedFilesystem{files: files, failRead: test.failRead}
|
||||||
_, err := testLoad(filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), test.options)
|
_, err := testLoad(filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), test.options)
|
||||||
if (err != nil) != test.wantErr {
|
if (err != nil) != test.wantErr {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
type secretDependency struct {
|
type secretDependency struct {
|
||||||
target Path
|
target Path
|
||||||
@@ -8,19 +12,45 @@ type secretDependency struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
||||||
|
return classifySecretPathsWithEvidence(schema, predicate, metadata, final, applications, nil, projections...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, evidence []*sourceVersionEvidence, projections ...*document.Node) map[string]bool {
|
||||||
paths := make(map[string]Path)
|
paths := make(map[string]Path)
|
||||||
|
candidateShapes := make(map[string][]Path)
|
||||||
|
var candidates []Path
|
||||||
|
addCandidate := func(path Path) {
|
||||||
|
pointer := path.String()
|
||||||
|
for _, prior := range candidateShapes[pointer] {
|
||||||
|
if slices.Equal(path.segments, prior.segments) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cloned := path.clone()
|
||||||
|
candidateShapes[pointer] = append(candidateShapes[pointer], cloned)
|
||||||
|
candidates = append(candidates, cloned)
|
||||||
|
}
|
||||||
for _, application := range applications {
|
for _, application := range applications {
|
||||||
walkDocument(application.value, application.path, func(path Path, _ *document.Node) {
|
walkHistoryApplication(application, func(_ Path, path Path, _ *document.Node) {
|
||||||
|
if len(application.sequences) != 0 {
|
||||||
|
addCandidate(path)
|
||||||
|
} else if parsed, err := ParsePath(path.String()); err == nil {
|
||||||
|
addCandidate(parsed)
|
||||||
|
}
|
||||||
addSecretPath(paths, path.String())
|
addSecretPath(paths, path.String())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
var dependencies []secretDependency
|
var dependencies []secretDependency
|
||||||
for pointer, history := range metadata.history {
|
for pointer, history := range metadata.history {
|
||||||
addSecretPath(paths, pointer)
|
addSecretPath(paths, pointer)
|
||||||
|
if path, err := ParsePath(pointer); err == nil {
|
||||||
|
addCandidate(path)
|
||||||
|
}
|
||||||
for _, change := range history {
|
for _, change := range history {
|
||||||
paths[change.Path.String()] = change.Path.clone()
|
paths[change.Path.String()] = change.Path.clone()
|
||||||
for _, dependency := range change.Dependencies {
|
for _, dependency := range change.Dependencies {
|
||||||
paths[dependency.String()] = dependency.clone()
|
paths[dependency.String()] = dependency.clone()
|
||||||
|
addCandidate(dependency)
|
||||||
dependencies = append(dependencies, secretDependency{
|
dependencies = append(dependencies, secretDependency{
|
||||||
target: change.Path.clone(), dependency: dependency.clone(),
|
target: change.Path.clone(), dependency: dependency.clone(),
|
||||||
})
|
})
|
||||||
@@ -32,8 +62,10 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
if operation.control != nil {
|
if operation.control != nil {
|
||||||
control := operation.control.path
|
control := operation.control.path
|
||||||
paths[control.String()] = control.clone()
|
paths[control.String()] = control.clone()
|
||||||
|
addCandidate(control)
|
||||||
for _, contributor := range operation.controlOrigin.contributors {
|
for _, contributor := range operation.controlOrigin.contributors {
|
||||||
paths[contributor.source.String()] = contributor.source.clone()
|
paths[contributor.source.String()] = contributor.source.clone()
|
||||||
|
addCandidate(contributor.source)
|
||||||
inherited = append(inherited, secretDependency{target: control.clone(), dependency: contributor.source.clone()})
|
inherited = append(inherited, secretDependency{target: control.clone(), dependency: contributor.source.clone()})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -53,6 +85,8 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
destination := appendInheritancePath(operation.transfer.to, relative)
|
destination := appendInheritancePath(operation.transfer.to, relative)
|
||||||
paths[pointer] = source
|
paths[pointer] = source
|
||||||
paths[destination.String()] = destination.clone()
|
paths[destination.String()] = destination.clone()
|
||||||
|
addCandidate(source)
|
||||||
|
addCandidate(destination)
|
||||||
inherited = append(inherited, secretDependency{target: destination, dependency: source})
|
inherited = append(inherited, secretDependency{target: destination, dependency: source})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +96,9 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
walkDocument(tree, Path{}, func(path Path, node *document.Node) {
|
walkDocument(tree, Path{}, func(path Path, node *document.Node) {
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
addSecretPath(paths, pointer)
|
addSecretPath(paths, pointer)
|
||||||
|
if parsed, err := ParsePath(pointer); err == nil {
|
||||||
|
addCandidate(parsed)
|
||||||
|
}
|
||||||
if node.Sensitive {
|
if node.Sensitive {
|
||||||
secret[pointer] = true
|
secret[pointer] = true
|
||||||
}
|
}
|
||||||
@@ -71,17 +108,38 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
classifyProjection(tree)
|
classifyProjection(tree)
|
||||||
}
|
}
|
||||||
classifyProjection(final)
|
classifyProjection(final)
|
||||||
for pointer, path := range paths {
|
classifyPath := func(path Path) {
|
||||||
if root, ok := schemaSecretRoot(schema, path); ok {
|
if root, ok := schemaSecretRoot(schema, path); ok {
|
||||||
secret[root.String()] = true
|
secret[root.String()] = true
|
||||||
}
|
}
|
||||||
if predicate != nil && predicate(path.clone()) {
|
if predicate != nil && predicate(path.clone()) {
|
||||||
secret[pointer] = true
|
secret[path.String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range candidates {
|
||||||
|
classifyPath(path)
|
||||||
|
}
|
||||||
|
// Classify each original evidence shape before folding results into pointer
|
||||||
|
// sensitivity. Numeric mapping keys and sequence indices can share a pointer.
|
||||||
|
for _, item := range evidence {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, resolved := range item.sensitivityPaths() {
|
||||||
|
addSecretPath(paths, resolved.String())
|
||||||
|
addCandidate(resolved)
|
||||||
|
classifyPath(resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return closeSecretDependencies(paths, secret, dependencies, inherited)
|
return closeSecretDependencies(paths, secret, dependencies, inherited)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addSecretEvidencePath(paths map[string]Path, destination, local Path) Path {
|
||||||
|
path := appendDestination(destination, local)
|
||||||
|
addSecretPath(paths, path.String())
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func addSecretPath(paths map[string]Path, pointer string) {
|
func addSecretPath(paths map[string]Path, pointer string) {
|
||||||
if existing, found := paths[pointer]; found && existing.valid() {
|
if existing, found := paths[pointer]; found && existing.valid() {
|
||||||
// Accumulator keys already match their paths' canonical spelling. History
|
// Accumulator keys already match their paths' canonical spelling. History
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
|
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
|
||||||
for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} {
|
for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} {
|
||||||
@@ -77,3 +80,123 @@ func TestSecretPredicateReceivesDetachedHistoricalPath(t *testing.T) {
|
|||||||
t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen)
|
t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSourceVersionEvidenceSensitivityUsesOriginalMountedPaths(t *testing.T) {
|
||||||
|
const canary = "PRIVATE-VERSION-CANARY"
|
||||||
|
type credential struct {
|
||||||
|
Value string `yaml:"credential" configkit:"secret"`
|
||||||
|
Public string `yaml:"public"`
|
||||||
|
}
|
||||||
|
type account struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
type tenant struct {
|
||||||
|
Groups map[string][]credential `yaml:"groups"`
|
||||||
|
Account account `yaml:"account" configkit:"secret"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
Labels map[string]string `yaml:"labels"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Tenant tenant `yaml:"tenant"`
|
||||||
|
}
|
||||||
|
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatal(issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := Keys("tenant")
|
||||||
|
sequencePath := Keys("groups").Key("blue").Index(2).Key("credential")
|
||||||
|
accountPath := Keys("account", "token")
|
||||||
|
predicatePath := Keys("settings", "private")
|
||||||
|
nullPath := Keys("settings", "nullable")
|
||||||
|
absentPath := Keys("settings", "missing")
|
||||||
|
publicPath := Keys("labels", "public")
|
||||||
|
evidence := []*sourceVersionEvidence{
|
||||||
|
{
|
||||||
|
path: sequencePath.clone(), resolvedPath: sequencePath.clone(), destination: destination.clone(),
|
||||||
|
version: Version{Present: true, Text: canary},
|
||||||
|
ancestors: []sourceVersionAncestor{{}, {path: Keys("groups")}, {path: Keys("groups").Key("blue")}, {path: Keys("groups").Key("blue").Index(2)}, {path: sequencePath.clone()}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: accountPath.clone(), resolvedPath: accountPath.clone(), destination: destination.clone(),
|
||||||
|
version: Version{Present: true, Text: canary},
|
||||||
|
ancestors: []sourceVersionAncestor{{}, {path: Keys("account")}, {path: accountPath.clone()}},
|
||||||
|
},
|
||||||
|
{path: predicatePath.clone(), resolvedPath: predicatePath.clone(), destination: destination.clone(), version: Version{Present: true, Text: canary}},
|
||||||
|
{path: nullPath.clone(), resolvedPath: nullPath.clone(), destination: destination.clone(), version: Version{Present: true, Null: true}},
|
||||||
|
{path: absentPath.clone(), resolvedPath: absentPath.clone(), destination: destination.clone()},
|
||||||
|
{path: publicPath.clone(), resolvedPath: publicPath.clone(), destination: destination.clone(), version: Version{Present: true, Text: canary}},
|
||||||
|
}
|
||||||
|
|
||||||
|
final := parseCompositionDocument(t, "final", "tenant:\n groups:\n blue:\n - public: shifted-neighbor\n labels:\n public: visible\n")
|
||||||
|
var sawOriginalSequencePath, sawAncestorPredicate bool
|
||||||
|
classified := classifySecretPathsWithEvidence(schema, func(path Path) bool {
|
||||||
|
if path.String() == appendDestination(destination, predicatePath).String() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if path.String() == appendDestination(destination, nullPath).String() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if path.String() == appendDestination(destination, absentPath).String() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if path.String() == appendDestination(destination, Keys("account")).String() {
|
||||||
|
sawAncestorPredicate = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if path.String() == appendDestination(destination, sequencePath).String() {
|
||||||
|
sawOriginalSequencePath = len(path.segments) == 5 && path.segments[3].kind == pathSegmentIndex && path.segments[3].index == 2
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, Metadata{}, final, []historyApplication{{path: destination, value: final}}, evidence)
|
||||||
|
|
||||||
|
for _, path := range []Path{
|
||||||
|
appendDestination(destination, sequencePath),
|
||||||
|
appendDestination(destination, Keys("account")),
|
||||||
|
appendDestination(destination, predicatePath),
|
||||||
|
appendDestination(destination, nullPath),
|
||||||
|
appendDestination(destination, absentPath),
|
||||||
|
} {
|
||||||
|
if !classified[path.String()] {
|
||||||
|
t.Errorf("evidence path %s was not classified: %#v", path, classified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shiftedNeighbor := appendDestination(destination, Keys("groups").Key("blue").Index(0).Key("public"))
|
||||||
|
if classified[shiftedNeighbor.String()] {
|
||||||
|
t.Fatalf("shifted neighboring payload was classified from declaration evidence: %#v", classified)
|
||||||
|
}
|
||||||
|
if classified[appendDestination(destination, publicPath).String()] || !sawOriginalSequencePath || !sawAncestorPredicate {
|
||||||
|
t.Fatalf("unclassified or semantic evidence candidates = %#v; original path seen %v, ancestor predicate seen %v", classified, sawOriginalSequencePath, sawAncestorPredicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceVersionEvidenceClassificationKeepsCollidingShapes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type header struct {
|
||||||
|
Version string `yaml:"version" configkit:"secret"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Meta map[string]header `yaml:"meta"`
|
||||||
|
}
|
||||||
|
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatal(issue)
|
||||||
|
}
|
||||||
|
keyPath := Keys("meta", "0", "version")
|
||||||
|
indexPath := Keys("meta").Index(0).Key("version")
|
||||||
|
for _, reverse := range []bool{false, true} {
|
||||||
|
evidence := []*sourceVersionEvidence{{resolvedPath: keyPath}, {resolvedPath: indexPath}}
|
||||||
|
if reverse {
|
||||||
|
evidence[0], evidence[1] = evidence[1], evidence[0]
|
||||||
|
}
|
||||||
|
var sawKey, sawIndex bool
|
||||||
|
classified := classifySecretPathsWithEvidence(schema, func(path Path) bool {
|
||||||
|
sawKey = sawKey || reflect.DeepEqual(path, keyPath)
|
||||||
|
sawIndex = sawIndex || reflect.DeepEqual(path, indexPath)
|
||||||
|
return false
|
||||||
|
}, Metadata{}, nil, nil, evidence)
|
||||||
|
if !classified[keyPath.String()] || !sawKey || !sawIndex {
|
||||||
|
t.Errorf("reverse=%v: classified=%v, key seen=%v, index seen=%v", reverse, classified, sawKey, sawIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
||||||
|
return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) {
|
||||||
if nativeContext(source, context) {
|
if nativeContext(source, context) {
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -46,7 +50,7 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
|||||||
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
|
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits)
|
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
|
||||||
}
|
}
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -58,10 +62,14 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
|||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return cloneSourceInfos(sources), []Diagnostic{value}
|
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
}
|
}
|
||||||
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits)
|
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
||||||
|
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) {
|
||||||
closed := false
|
closed := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if !closed {
|
if !closed {
|
||||||
@@ -126,7 +134,15 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
|
|||||||
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
|
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
|
||||||
return sources, diagnostics
|
return sources, diagnostics
|
||||||
}
|
}
|
||||||
if versionNode := fragmentVersionClaim(overlayRoot, rule); versionNode != nil {
|
overlayPayload := overlayRoot
|
||||||
|
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||||
|
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping)
|
||||||
|
if len(failures) != 0 {
|
||||||
|
return sources, failures
|
||||||
|
}
|
||||||
|
overlayPayload = processed.payload
|
||||||
|
}
|
||||||
|
if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil {
|
||||||
location := publicLocation(versionNode.Location)
|
location := publicLocation(versionNode.Location)
|
||||||
return sources, []Diagnostic{{
|
return sources, []Diagnostic{{
|
||||||
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
@@ -139,7 +155,7 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
|
|||||||
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
if diagnostics := base.applyOverlay(overlayRoot, nil, nil, LayerOverlay); len(diagnostics) != 0 {
|
if diagnostics := base.applyOverlay(overlayPayload, nil, nil, LayerOverlay); len(diagnostics) != 0 {
|
||||||
return sources, diagnostics
|
return sources, diagnostics
|
||||||
}
|
}
|
||||||
base.current.inputBytes += int64(len(data))
|
base.current.inputBytes += int64(len(data))
|
||||||
|
|||||||
54
internal/engine/sequence_registration.go
Normal file
54
internal/engine/sequence_registration.go
Normal 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)
|
||||||
|
}
|
||||||
306
internal/engine/sequence_registration_test.go
Normal file
306
internal/engine/sequence_registration_test.go
Normal 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
|
||||||
|
}
|
||||||
464
internal/engine/source_version.go
Normal file
464
internal/engine/source_version.go
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
type sourceVersionMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceVersionModeDefault sourceVersionMode = iota
|
||||||
|
sourceVersionModeIfPresent
|
||||||
|
sourceVersionModeRequired
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceVersionModeFromFacade converts the root-owned public mode while
|
||||||
|
// keeping the engine's policy type private. Unknown values remain intact for
|
||||||
|
// pre-acquisition validation.
|
||||||
|
func SourceVersionModeFromFacade(value uint8) sourceVersionMode {
|
||||||
|
return sourceVersionMode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceVersionRole uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceVersionMain sourceVersionRole = iota
|
||||||
|
sourceVersionContribution
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceVersionAncestor struct {
|
||||||
|
path Path
|
||||||
|
kind document.Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceVersionEvidence struct {
|
||||||
|
sourceID string
|
||||||
|
path Path
|
||||||
|
resolvedPath Path
|
||||||
|
destination Path
|
||||||
|
projectedPaths []Path
|
||||||
|
version Version
|
||||||
|
location *Location
|
||||||
|
ancestors []sourceVersionAncestor
|
||||||
|
absence sourceVersionPathAbsence
|
||||||
|
checked bool
|
||||||
|
redacted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceVersionState struct {
|
||||||
|
localRule *VersionRule
|
||||||
|
effectiveRule *VersionRule
|
||||||
|
mode sourceVersionMode
|
||||||
|
mainProcessing *sourceVersionProcessingResult
|
||||||
|
evidence []*sourceVersionEvidence
|
||||||
|
diagnostics []Diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceInfosWithVersionEvidence(sources []SourceInfo, evidence []*sourceVersionEvidence, secrets map[string]bool, conservative bool) []SourceInfo {
|
||||||
|
result := cloneSourceInfos(sources)
|
||||||
|
if len(evidence) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
sensitivity := newDiffSensitivity(secrets, nil)
|
||||||
|
for _, item := range evidence {
|
||||||
|
if item == nil || item.sourceID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for index := range result {
|
||||||
|
if result[index].ID == item.sourceID {
|
||||||
|
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity *diffSensitivity, conservative bool) *SourceVersionCheck {
|
||||||
|
path := evidence.resolvedPath
|
||||||
|
if !path.valid() {
|
||||||
|
path = evidence.path
|
||||||
|
}
|
||||||
|
version := evidence.version
|
||||||
|
redacted := evidence.redacted
|
||||||
|
if conservative {
|
||||||
|
redacted = redacted || version.Text != ""
|
||||||
|
} else if sensitivity.isSensitiveAtOrAbove(evidence.sensitivityPaths()[0].String()) {
|
||||||
|
redacted = true
|
||||||
|
}
|
||||||
|
if redacted {
|
||||||
|
version.Text = ""
|
||||||
|
}
|
||||||
|
return &SourceVersionCheck{
|
||||||
|
Path: path.clone(), Version: version, Location: cloneLocation(evidence.location), Redacted: redacted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sensitivityPaths preserves source-local evidence while describing its assigned
|
||||||
|
// application coordinates. Before assembly, the original mount is the fallback.
|
||||||
|
func (evidence *sourceVersionEvidence) sensitivityPaths() []Path {
|
||||||
|
if evidence.projectedPaths != nil {
|
||||||
|
return evidence.projectedPaths
|
||||||
|
}
|
||||||
|
local := evidence.resolvedPath
|
||||||
|
if !local.valid() {
|
||||||
|
local = evidence.path
|
||||||
|
}
|
||||||
|
paths := make([]Path, 0, len(evidence.ancestors)+1)
|
||||||
|
paths = append(paths, appendDestination(evidence.destination, local))
|
||||||
|
for _, ancestor := range evidence.ancestors {
|
||||||
|
paths = append(paths, appendDestination(evidence.destination, ancestor.path))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement placementMode) (sourceVersionProcessingResult, []Diagnostic) {
|
||||||
|
if state == nil || state.mode == sourceVersionModeDefault {
|
||||||
|
return sourceVersionProcessingResult{}, nil
|
||||||
|
}
|
||||||
|
projectedDestination := destination.clone()
|
||||||
|
if placement == placementModeSequenceItem {
|
||||||
|
projectedDestination = projectedDestination.Index(0)
|
||||||
|
}
|
||||||
|
result := processSourceVersion(root, state.localRule, role, projectedDestination, state.mode)
|
||||||
|
if result.evidence != nil {
|
||||||
|
result.evidence.sourceID = sourceID
|
||||||
|
state.evidence = append(state.evidence, result.evidence)
|
||||||
|
}
|
||||||
|
var failures []Diagnostic
|
||||||
|
for _, diagnostic := range result.diagnostics {
|
||||||
|
if diagnostic.Severity == SeverityWarning {
|
||||||
|
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||||
|
} else if diagnostic.Severity == SeverityError {
|
||||||
|
failures = append(failures, diagnostic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, failures
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceVersionProcessingResult struct {
|
||||||
|
payload *document.Node
|
||||||
|
imports []importDeclaration
|
||||||
|
evidence *sourceVersionEvidence
|
||||||
|
diagnostics []Diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule {
|
||||||
|
return effectiveSourceVersionRuleForPlacement(local, destination, mode, placementModeMapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveSourceVersionRuleForPlacement(local *VersionRule, destination Path, mode sourceVersionMode, placement placementMode) *VersionRule {
|
||||||
|
if local == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mode == sourceVersionModeDefault {
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
effective := *local
|
||||||
|
prefix := destination.clone()
|
||||||
|
if placement == placementModeSequenceItem {
|
||||||
|
prefix = prefix.Index(0)
|
||||||
|
}
|
||||||
|
effective.Path = Path{
|
||||||
|
segments: append(clonePathSegments(prefix.segments, len(local.Path.segments)), local.Path.segments...),
|
||||||
|
invalid: prefix.invalid || local.Path.invalid,
|
||||||
|
}
|
||||||
|
return &effective
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceVersionPathAbsence uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceVersionPathPresent sourceVersionPathAbsence = iota
|
||||||
|
sourceVersionPathInvalid
|
||||||
|
sourceVersionMemberMissing
|
||||||
|
sourceVersionIndexUnavailable
|
||||||
|
sourceVersionPathObstructed
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceVersionPath struct {
|
||||||
|
node *document.Node
|
||||||
|
present bool
|
||||||
|
absence sourceVersionPathAbsence
|
||||||
|
nearest document.Location
|
||||||
|
resolvedPath Path
|
||||||
|
ancestors []sourceVersionAncestor
|
||||||
|
}
|
||||||
|
|
||||||
|
func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVersionRole, destination Path, mode sourceVersionMode) sourceVersionProcessingResult {
|
||||||
|
if role != sourceVersionMain && role != sourceVersionContribution {
|
||||||
|
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source role is invalid")}}
|
||||||
|
}
|
||||||
|
if mode != sourceVersionModeDefault && mode != sourceVersionModeIfPresent && mode != sourceVersionModeRequired {
|
||||||
|
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source-version mode is invalid")}}
|
||||||
|
}
|
||||||
|
if mode != sourceVersionModeDefault && (rule == nil || rule.Check == nil || !rule.Path.valid()) {
|
||||||
|
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source-version checking requires a valid version rule and checker")}}
|
||||||
|
}
|
||||||
|
if root == nil {
|
||||||
|
if role == sourceVersionMain && mode != sourceVersionModeDefault {
|
||||||
|
return sourceVersionProcessingResult{diagnostics: canonicalDiagnostics(reportVersion(rule, Version{}, nil))}
|
||||||
|
}
|
||||||
|
return sourceVersionProcessingResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, imports, diagnostics := splitImportDirectives(root)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return sourceVersionProcessingResult{diagnostics: diagnostics}
|
||||||
|
}
|
||||||
|
result := sourceVersionProcessingResult{payload: payload, imports: imports}
|
||||||
|
if mode == sourceVersionModeDefault {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := resolveSourceVersionPath(root, rule.Path)
|
||||||
|
evidence := &sourceVersionEvidence{
|
||||||
|
path: rule.Path.clone(), resolvedPath: resolved.resolvedPath,
|
||||||
|
destination: destination.clone(), ancestors: resolved.ancestors,
|
||||||
|
absence: resolved.absence,
|
||||||
|
}
|
||||||
|
location := resolved.nearest
|
||||||
|
if resolved.present {
|
||||||
|
location = resolved.node.Location
|
||||||
|
}
|
||||||
|
if locationKnown(location) {
|
||||||
|
public := publicLocation(location)
|
||||||
|
evidence.location = &public
|
||||||
|
}
|
||||||
|
result.evidence = evidence
|
||||||
|
|
||||||
|
interpreted := interpretVersion(rule, root)
|
||||||
|
evidence.version = interpreted.version
|
||||||
|
if resolved.present && sourceVersionControlOverlap(root, evidence.resolvedPath) {
|
||||||
|
evidence.redactText()
|
||||||
|
result.payload = nil
|
||||||
|
result.diagnostics = []Diagnostic{sourceVersionOverlapDiagnostic(rule, evidence)}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if hasErrors(interpreted.diagnostics) {
|
||||||
|
evidence.redactText()
|
||||||
|
result.payload = nil
|
||||||
|
result.diagnostics = canonicalDiagnostics(interpreted.diagnostics)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if !interpreted.version.Present {
|
||||||
|
if mode == sourceVersionModeRequired {
|
||||||
|
evidence.redactText()
|
||||||
|
result.payload = nil
|
||||||
|
result.diagnostics = []Diagnostic{sourceVersionMissingDiagnostic(rule, evidence)}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if role == sourceVersionContribution {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence.checked = true
|
||||||
|
result.diagnostics = canonicalDiagnostics(reportVersion(rule, interpreted.version, root))
|
||||||
|
if hasErrors(result.diagnostics) {
|
||||||
|
evidence.redactText()
|
||||||
|
result.payload = nil
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if role == sourceVersionContribution && interpreted.version.Present {
|
||||||
|
if !removeSourceVersionNode(result.payload, evidence.resolvedPath) {
|
||||||
|
evidence.redactText()
|
||||||
|
result.payload = nil
|
||||||
|
result.diagnostics = []Diagnostic{sourceVersionOptionsDiagnostic("selected source version could not be removed from its owned payload")}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionOptionsDiagnostic(message string) Diagnostic {
|
||||||
|
return Diagnostic{Code: CodeOptionsInvalid, Severity: SeverityError, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionMissingDiagnostic(rule *VersionRule, evidence *sourceVersionEvidence) Diagnostic {
|
||||||
|
return Diagnostic{
|
||||||
|
Code: CodeVersionError, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "required schema version is missing", Primary: cloneLocation(evidence.location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionOverlapDiagnostic(rule *VersionRule, evidence *sourceVersionEvidence) Diagnostic {
|
||||||
|
return Diagnostic{
|
||||||
|
Code: CodeVersionError, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "schema version path overlaps a structural directive", Primary: cloneLocation(evidence.location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (evidence *sourceVersionEvidence) redactText() {
|
||||||
|
if evidence == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evidence.version.Text = ""
|
||||||
|
evidence.redacted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath {
|
||||||
|
// Refine only segments whose container shape is known, keeping the full
|
||||||
|
// requested suffix when lookup stops before the declaration.
|
||||||
|
resolved := sourceVersionPath{absence: sourceVersionPathInvalid, resolvedPath: path.clone()}
|
||||||
|
if root == nil || !path.valid() {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
current := root
|
||||||
|
resolved.nearest = current.Location
|
||||||
|
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind})
|
||||||
|
for position, segment := range path.segments {
|
||||||
|
switch current.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
resolved.absence = sourceVersionPathObstructed
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentKey, key: segment.key}
|
||||||
|
found := false
|
||||||
|
for _, entry := range current.Mapping {
|
||||||
|
if entry.Key == segment.key {
|
||||||
|
current = entry.Value
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
resolved.absence = sourceVersionMemberMissing
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
index, ok := schemaIndex(segment)
|
||||||
|
if !ok {
|
||||||
|
resolved.absence = sourceVersionIndexUnavailable
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentIndex, index: index}
|
||||||
|
if index >= len(current.Sequence) {
|
||||||
|
resolved.absence = sourceVersionIndexUnavailable
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
current = current.Sequence[index]
|
||||||
|
default:
|
||||||
|
resolved.absence = sourceVersionPathObstructed
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
resolved.nearest = current.Location
|
||||||
|
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{
|
||||||
|
path: Path{segments: clonePathSegments(resolved.resolvedPath.segments[:position+1], 0)}, kind: current.Kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resolved.node = current
|
||||||
|
resolved.present = true
|
||||||
|
resolved.absence = sourceVersionPathPresent
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionControlOverlap(root *document.Node, path Path) bool {
|
||||||
|
if root == nil || !path.valid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if root.Kind == document.Mapping {
|
||||||
|
for _, entry := range root.Mapping {
|
||||||
|
if entry.Key == directiveKey && pathHasPrefix(path, Keys(directiveKey)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sourceVersionInheritanceOverlap(root, Path{}, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceVersionInheritanceOverlap(node *document.Node, current, path Path) bool {
|
||||||
|
if node == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if node.Kind == document.Mapping {
|
||||||
|
if _, found := inheritanceDirective(node); found {
|
||||||
|
controlPath := current.Key(inheritanceKey)
|
||||||
|
if pathHasPrefix(path, controlPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key == inheritanceKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sourceVersionInheritanceOverlap(entry.Value, current.Key(entry.Key), path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if node.Kind == document.Sequence {
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
if sourceVersionInheritanceOverlap(child, current.Index(index), path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathHasPrefix(path, prefix Path) bool {
|
||||||
|
if !path.valid() || !prefix.valid() || len(prefix.segments) > len(path.segments) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index, segment := range prefix.segments {
|
||||||
|
candidate := path.segments[index]
|
||||||
|
if candidate.kind != segment.kind || candidate.key != segment.key || candidate.index != segment.index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSourceVersionNode(root *document.Node, path Path) bool {
|
||||||
|
if root == nil || !path.valid() || len(path.segments) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
current := root
|
||||||
|
for _, segment := range path.segments[:len(path.segments)-1] {
|
||||||
|
switch current.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, entry := range current.Mapping {
|
||||||
|
if entry.Key == segment.key {
|
||||||
|
current = entry.Value
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
index, ok := schemaIndex(segment)
|
||||||
|
if !ok || index >= len(current.Sequence) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
current = current.Sequence[index]
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last := path.segments[len(path.segments)-1]
|
||||||
|
switch current.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if last.kind == pathSegmentIndex {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index, entry := range current.Mapping {
|
||||||
|
if entry.Key == last.key {
|
||||||
|
current.Mapping = append(current.Mapping[:index], current.Mapping[index+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
index, ok := schemaIndex(last)
|
||||||
|
if !ok || index >= len(current.Sequence) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
current.Sequence = append(current.Sequence[:index], current.Sequence[index+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
1443
internal/engine/source_version_test.go
Normal file
1443
internal/engine/source_version_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -187,34 +187,59 @@ func validationValueMutations(before, after *document.Node) []Diagnostic {
|
|||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type versionCheckResult struct {
|
||||||
|
version Version
|
||||||
|
diagnostics []Diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||||
|
return checkVersionResult(rule, root).diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkVersionResult(rule *VersionRule, root *document.Node) versionCheckResult {
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
return nil
|
return versionCheckResult{}
|
||||||
}
|
}
|
||||||
version := Version{}
|
result := interpretVersion(rule, root)
|
||||||
|
if hasErrors(result.diagnostics) {
|
||||||
|
result.diagnostics = canonicalDiagnostics(result.diagnostics)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.diagnostics = append(result.diagnostics, reportVersion(rule, result.version, root)...)
|
||||||
|
result.diagnostics = canonicalDiagnostics(result.diagnostics)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpretVersion(rule *VersionRule, root *document.Node) versionCheckResult {
|
||||||
|
result := versionCheckResult{}
|
||||||
node, present := documentAtPath(root, rule.Path)
|
node, present := documentAtPath(root, rule.Path)
|
||||||
if present {
|
if present {
|
||||||
version.Present = true
|
result.version.Present = true
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case document.Null:
|
case document.Null:
|
||||||
version.Null = true
|
result.version.Null = true
|
||||||
case document.String:
|
case document.String:
|
||||||
version.Text = node.Text
|
result.version.Text = node.Text
|
||||||
case document.Number:
|
case document.Number:
|
||||||
if isIntegerVersion(node) {
|
if isIntegerVersion(node) {
|
||||||
version.Text = node.Text
|
result.version.Text = node.Text
|
||||||
break
|
return result
|
||||||
}
|
}
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||||
primary := documentLocationPointer(location)
|
primary := documentLocationPointer(location)
|
||||||
return []Diagnostic{{
|
result.diagnostics = []Diagnostic{{
|
||||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
Message: "schema version must be a string, integer, or null", Primary: primary,
|
Message: "schema version must be a string, integer, or null", Primary: primary,
|
||||||
}}
|
}}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportVersion(rule *VersionRule, version Version, root *document.Node) []Diagnostic {
|
||||||
state := &validationState{
|
state := &validationState{
|
||||||
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
||||||
invalidPath: CodeVersionError, document: root,
|
invalidPath: CodeVersionError, document: root,
|
||||||
@@ -229,7 +254,7 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
|||||||
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return canonicalDiagnostics(state.diagnostics)
|
return state.diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
func isIntegerVersion(node *document.Node) bool {
|
func isIntegerVersion(node *document.Node) bool {
|
||||||
|
|||||||
26
layered.go
26
layered.go
@@ -8,11 +8,16 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
|||||||
// reader ownership rules.
|
// reader ownership rules.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
Source Source
|
Source Source
|
||||||
// Destination selects the mapping path where Source and its imported
|
// Destination establishes the mapping path for Source. Imports inherit this
|
||||||
// application contributions are placed. Its zero value keeps root placement.
|
// path or append a relative destination declared in YAML. Its zero value
|
||||||
|
// keeps root placement.
|
||||||
Destination Path
|
Destination Path
|
||||||
Imports bool
|
// PlacementMode defaults to mapping placement. Sequence-item placement wraps
|
||||||
Filesystem *FilesystemContext
|
// 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
|
// Underlay contains inputs that must compose strictly within one tier. Tiers
|
||||||
@@ -22,8 +27,14 @@ type Underlay struct{ Inputs []Input }
|
|||||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||||
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
|
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
|
||||||
// ignores it. Overrides remain explicit CLI records, not general mappings.
|
// ignores it. Overrides remain explicit CLI records, not general mappings.
|
||||||
|
// SourceVersionMode applies one declaration policy to main, underlay, import,
|
||||||
|
// and selected overlay documents. Enabled modes interpret the version path
|
||||||
|
// source-locally, consume accepted non-main declarations, and protect main's
|
||||||
|
// destination-prefixed path. The zero value preserves existing behavior; see
|
||||||
|
// the shared source-version contract in docs/config.md.
|
||||||
type LayeredOptions[T any] struct {
|
type LayeredOptions[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
|
SourceVersionMode SourceVersionMode
|
||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
@@ -48,15 +59,18 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T],
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toEngineInput(input Input) engine.Input {
|
func toEngineInput(input Input) engine.Input {
|
||||||
return engine.Input{
|
result := engine.Input{
|
||||||
Source: input.Source.toEngine(), Imports: input.Imports,
|
Source: input.Source.toEngine(), Imports: input.Imports,
|
||||||
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
||||||
}
|
}
|
||||||
|
return engine.InputWithPlacement(result, uint8(input.PlacementMode))
|
||||||
}
|
}
|
||||||
|
|
||||||
func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] {
|
func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] {
|
||||||
result := engine.LayeredOptions[T]{
|
result := engine.LayeredOptions[T]{
|
||||||
Definition: toEngineDefinition(options.Definition), Overlay: options.Overlay,
|
Definition: toEngineDefinition(options.Definition),
|
||||||
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
|
Overlay: options.Overlay,
|
||||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||||
}
|
}
|
||||||
if options.Underlays != nil {
|
if options.Underlays != nil {
|
||||||
|
|||||||
96
lifecycle.go
96
lifecycle.go
@@ -15,17 +15,83 @@ type Definition[T any] struct {
|
|||||||
Validate func(*T, *Validation)
|
Validate func(*T, *Validation)
|
||||||
Version *VersionRule
|
Version *VersionRule
|
||||||
Secret func(Path) bool
|
Secret func(Path) bool
|
||||||
|
// SequenceCompositions registers mapping paths whose sequence contributions
|
||||||
|
// strictly append distinct records by the selected literal identity field.
|
||||||
|
// Invalid or conflicting registrations fail option validation. Repeated input
|
||||||
|
// identities fail strict composition after source and helper processing.
|
||||||
|
// Registration paths are copied when a load begins; definitions are read-only
|
||||||
|
// after being shared.
|
||||||
|
SequenceCompositions []SequenceComposition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlacementMode selects how an input mapping is placed at its Destination.
|
||||||
|
// The zero value, PlacementModeMapping, preserves ordinary mapping placement.
|
||||||
|
// Unknown values and sequence-item placement at the root fail option validation.
|
||||||
|
type PlacementMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PlacementModeMapping places a source mapping at Destination.
|
||||||
|
PlacementModeMapping PlacementMode = iota
|
||||||
|
// PlacementModeSequenceItem wraps a source mapping as one item at Destination.
|
||||||
|
PlacementModeSequenceItem
|
||||||
|
)
|
||||||
|
|
||||||
|
// SequenceComposition registers a sequence path for strict identity-based
|
||||||
|
// composition. Path is copied at load start; IdentityField is one literal key,
|
||||||
|
// including the empty string. Invalid or conflicting registrations fail option
|
||||||
|
// validation.
|
||||||
|
type SequenceComposition struct {
|
||||||
|
// Path selects a literal mapping path; registration paths are copied at load
|
||||||
|
// start. Invalid paths fail option validation; the root path is harmless.
|
||||||
|
Path Path
|
||||||
|
// IdentityField is one literal mapping key and may be empty. Records must
|
||||||
|
// contain a string at this key; malformed records fail strict composition.
|
||||||
|
IdentityField string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceVersionMode controls schema-version declarations across one complete
|
||||||
|
// load. The zero value preserves the existing main-only effective-path check
|
||||||
|
// and its protected-path behavior. Enabled modes check participating documents
|
||||||
|
// at source-local Definition.Version.Path coordinates and consume accepted
|
||||||
|
// non-main declarations. See the shared source-version contract in docs/config.md.
|
||||||
|
type SourceVersionMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SourceVersionModeDefault preserves the existing main-version check and
|
||||||
|
// rejects version-path contributions from imported, underlay, and overlay
|
||||||
|
// payloads.
|
||||||
|
SourceVersionModeDefault SourceVersionMode = iota
|
||||||
|
// SourceVersionModeIfPresent checks each participating document that has a
|
||||||
|
// declaration. A missing main declaration still reaches the checker; an
|
||||||
|
// absent non-main declaration skips it. Accepted non-main declarations are
|
||||||
|
// consumed before composition.
|
||||||
|
SourceVersionModeIfPresent
|
||||||
|
// SourceVersionModeRequired requires a declaration in each participating
|
||||||
|
// document. NoSource retains the existing absent-main checker behavior.
|
||||||
|
// Accepted non-main declarations are consumed before composition.
|
||||||
|
SourceVersionModeRequired
|
||||||
|
)
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
|
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||||
|
// document's local coordinates. Placement projects checks and main protection
|
||||||
|
// through the input destination; item placement adds the source's item
|
||||||
|
// coordinate to that projection. See docs/config.md for the full contract.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
// Destination places the ordinary source beneath literal mapping keys. Its
|
||||||
Overlay string
|
// zero value means the application root. Invalid paths fail before acquisition.
|
||||||
Filesystem *FilesystemContext
|
Destination Path
|
||||||
Overrides []Override
|
// PlacementMode defaults to mapping placement; unknown values and root item
|
||||||
|
// placement fail before acquisition.
|
||||||
|
PlacementMode PlacementMode
|
||||||
|
Imports bool
|
||||||
|
Overlay string
|
||||||
|
Filesystem *FilesystemContext
|
||||||
|
Overrides []Override
|
||||||
|
SourceVersionMode SourceVersionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
||||||
@@ -53,7 +119,10 @@ type Override struct {
|
|||||||
type Resolution struct{ value *engine.Resolution }
|
type Resolution struct{ value *engine.Resolution }
|
||||||
|
|
||||||
// VersionRule declares the input path and application checker for a schema version.
|
// VersionRule declares the input path and application checker for a schema version.
|
||||||
// At that path, string and decimal-integer input is supplied as Version.Text.
|
// Default source-version mode interprets Path as the existing effective path;
|
||||||
|
// enabled modes interpret it in each participating document's local coordinates.
|
||||||
|
// At the selected path, strings and strict decimal integers supply their exact
|
||||||
|
// spelling as Version.Text. See docs/config.md for the complete contract.
|
||||||
type VersionRule struct {
|
type VersionRule struct {
|
||||||
Path Path
|
Path Path
|
||||||
Check func(Version, *Validation) error
|
Check func(Version, *Validation) error
|
||||||
@@ -226,10 +295,12 @@ func (v *Validation) engineContext() *engine.Validation {
|
|||||||
|
|
||||||
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
||||||
result := engine.Options[T]{
|
result := engine.Options[T]{
|
||||||
Definition: toEngineDefinition(options.Definition),
|
Definition: toEngineDefinition(options.Definition),
|
||||||
Imports: options.Imports,
|
Imports: options.Imports,
|
||||||
Overlay: options.Overlay,
|
Overlay: options.Overlay,
|
||||||
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
}
|
}
|
||||||
|
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
||||||
}
|
}
|
||||||
@@ -258,6 +329,15 @@ func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemCon
|
|||||||
|
|
||||||
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
|
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
|
||||||
result := engine.Definition[T]{Defaults: definition.Defaults}
|
result := engine.Definition[T]{Defaults: definition.Defaults}
|
||||||
|
if definition.SequenceCompositions != nil {
|
||||||
|
paths := make([]engine.Path, len(definition.SequenceCompositions))
|
||||||
|
identityFields := make([]string, len(definition.SequenceCompositions))
|
||||||
|
for index, registration := range definition.SequenceCompositions {
|
||||||
|
paths[index] = toEnginePath(registration.Path.clone())
|
||||||
|
identityFields[index] = registration.IdentityField
|
||||||
|
}
|
||||||
|
result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields)
|
||||||
|
}
|
||||||
if definition.Resolve != nil {
|
if definition.Resolve != nil {
|
||||||
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
|
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
|
||||||
return definition.Resolve(configuration, &Resolution{value: resolution})
|
return definition.Resolve(configuration, &Resolution{value: resolution})
|
||||||
|
|||||||
53
records.go
53
records.go
@@ -189,13 +189,41 @@ type SourceInfo struct {
|
|||||||
ID string
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Path string
|
Path string
|
||||||
// Destination is the effective mapping path. Imported sources retain their
|
// Destination is the effective mapping or sequence-container path. It is a
|
||||||
// input's destination; overlay and CLI sources use the root path.
|
// detached snapshot. A fallback container is marked by DestinationUnresolved.
|
||||||
Destination Path
|
Destination Path
|
||||||
Layer Layer
|
// PlacementMode describes how this source was placed. Mapping is the zero
|
||||||
Kind SourceKind
|
// value; CLI and overlay sources report mapping placement unless item placement
|
||||||
UnderlayIndex int
|
// was applied to that source.
|
||||||
ImportedFrom *Location
|
PlacementMode PlacementMode
|
||||||
|
// DestinationUnresolved marks Destination as the nearest resolved enclosing
|
||||||
|
// container when an enclosing item index is not yet assigned. It is false for
|
||||||
|
// exact destinations, including an item source's own sequence container.
|
||||||
|
DestinationUnresolved bool
|
||||||
|
Layer Layer
|
||||||
|
Kind SourceKind
|
||||||
|
UnderlayIndex int
|
||||||
|
ImportedFrom *Location
|
||||||
|
// VersionCheck is sanitized evidence for source-local declaration processing
|
||||||
|
// in an enabled source-version load. It is nil when that processing did not
|
||||||
|
// occur, including default mode and synthetic CLI sources. During callbacks
|
||||||
|
// and failed loads, text is conservatively withheld; successful final source
|
||||||
|
// records reflect completed sensitivity classification.
|
||||||
|
VersionCheck *SourceVersionCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceVersionCheck describes a source-local version declaration observed
|
||||||
|
// during an enabled load. A non-nil record with Present false records observed
|
||||||
|
// absence, but does not imply that a checker ran or accepted a value. Text is
|
||||||
|
// empty when sensitivity classification or conservative callback-time/failed-
|
||||||
|
// load redaction withholds it; Present and Null retain their meaning. Copies
|
||||||
|
// returned by source queries are detached. See docs/config.md for disclosure
|
||||||
|
// and lifecycle details.
|
||||||
|
type SourceVersionCheck struct {
|
||||||
|
Path Path
|
||||||
|
Version Version
|
||||||
|
Location *Location
|
||||||
|
Redacted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic is a safe structured problem report. Message and source labels must
|
// Diagnostic is a safe structured problem report. Message and source labels must
|
||||||
@@ -266,9 +294,20 @@ func cloneLocations(locations []Location) []Location {
|
|||||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||||
source.Destination = source.Destination.clone()
|
source.Destination = source.Destination.clone()
|
||||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||||
|
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *value
|
||||||
|
cloned.Path = value.Path.clone()
|
||||||
|
cloned.Location = cloneLocation(value.Location)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
||||||
if sources == nil {
|
if sources == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
func TestOverlayPublicValues(t *testing.T) {
|
func TestOverlayPublicValues(t *testing.T) {
|
||||||
if LayerOverlay != "overlay" {
|
if LayerOverlay != "overlay" {
|
||||||
@@ -23,14 +27,17 @@ func TestOverlayPublicValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
||||||
destination := Keys("collections", "a/b", "0")
|
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)
|
internal := toEngineSourceInfo(public)
|
||||||
if internal.Destination.String() != destination.String() {
|
if internal.Destination.String() != destination.String() || engine.SourceInfoPlacementModeForFacade(internal) != uint8(PlacementModeSequenceItem) || !engine.SourceInfoDestinationUnresolvedForFacade(internal) {
|
||||||
t.Fatalf("engine destination = %q, want %q", internal.Destination.String(), destination.String())
|
t.Fatalf("engine source evidence = %#v", internal)
|
||||||
}
|
}
|
||||||
converted := fromEngineSourceInfo(internal)
|
converted := fromEngineSourceInfo(internal)
|
||||||
if converted.Destination.String() != destination.String() {
|
if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || !converted.DestinationUnresolved {
|
||||||
t.Fatalf("public destination = %q, want %q", converted.Destination.String(), destination.String())
|
t.Fatalf("public source evidence = %#v", converted)
|
||||||
}
|
}
|
||||||
copy := cloneSourceInfo(converted)
|
copy := cloneSourceInfo(converted)
|
||||||
copy.Destination = Path{}
|
copy.Destination = Path{}
|
||||||
@@ -38,3 +45,29 @@ func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
|||||||
t.Fatalf("source record destination changed through clone: %q", converted.Destination.String())
|
t.Fatalf("source record destination changed through clone: %q", converted.Destination.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
|
||||||
|
path := Keys("records").Index(2).Key("version")
|
||||||
|
source := SourceInfo{
|
||||||
|
ID: "source-1",
|
||||||
|
VersionCheck: &SourceVersionCheck{
|
||||||
|
Path: path, Version: Version{Present: true, Text: "123"},
|
||||||
|
Location: &Location{SourceID: "source-1", Line: 4, Column: 8},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
internal := toEngineSourceInfo(source)
|
||||||
|
if internal.VersionCheck == nil || internal.VersionCheck.Path.String() != path.String() ||
|
||||||
|
internal.VersionCheck.Version.Text != "123" || internal.VersionCheck.Location == nil || internal.VersionCheck.Location.Line != 4 {
|
||||||
|
t.Fatalf("engine source version check = %#v", internal.VersionCheck)
|
||||||
|
}
|
||||||
|
converted := fromEngineSourceInfo(internal)
|
||||||
|
copy := cloneSourceInfo(converted)
|
||||||
|
copy.VersionCheck.Path = Keys("changed")
|
||||||
|
copy.VersionCheck.Version.Text = "changed"
|
||||||
|
copy.VersionCheck.Location.Line = 99
|
||||||
|
copy.VersionCheck.Redacted = true
|
||||||
|
if converted.VersionCheck.Path.String() != path.String() || converted.VersionCheck.Version.Text != "123" ||
|
||||||
|
converted.VersionCheck.Location.Line != 4 || converted.VersionCheck.Redacted {
|
||||||
|
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user