Compare commits
193 Commits
e66c37161c
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ff204bf3d | |||
| b3ec860fac | |||
| 435dea102f | |||
| ea46001f78 | |||
| a0e5693d07 | |||
| 5c5f2af6cc | |||
| 8253993005 | |||
| d0848290da | |||
| e94a0642ff | |||
| 571d063675 | |||
| d0a605afbb | |||
| 7a83704f43 | |||
| ac5bf29ddc | |||
| cce8356b8e | |||
| b746dcc609 | |||
| 9c32b8d3b5 | |||
| c73daf891d | |||
| ae038a5165 | |||
| 367bbaaa1b | |||
| fafc28421f | |||
| 1a673753b9 | |||
| 988e49bb09 | |||
| 5bb4f364b7 | |||
| af84107905 | |||
| f94dd9b961 | |||
| 146e7e56d9 | |||
| d2e20a304a | |||
| 8d40983271 | |||
| 25cf7ce05c | |||
| 57edb8ec19 | |||
| 42663cf07c | |||
| 085b57eeb6 | |||
| dcf0379e02 | |||
| 8ecfedc91b | |||
| f7624ae92a | |||
| a035df9845 | |||
| e6598810e0 | |||
| f8cbb2ed87 | |||
| af80ccb90c | |||
| 0be7cdb00e | |||
| e01aeca761 | |||
| d4133f23f5 | |||
| 5af71a7533 | |||
| 41b5dff69b | |||
| ecfb78d84d | |||
| 041c4cff02 | |||
| 23f22d8982 | |||
| 0023617a07 | |||
| 33a51606da | |||
| 268fd03579 | |||
| 9d4d2e2a17 | |||
| 36ff9170b7 | |||
| 9e75496664 | |||
| 80d8c1dc82 | |||
| acd9e9a9fa | |||
| 89a9684f0d | |||
| 9a7027a04c | |||
| dfeaf90320 | |||
| d03e56398b | |||
| 628fc53d2c | |||
| 5fec16a768 | |||
| f6b02dec03 | |||
| 7b04dc6a50 | |||
| 12fdf0d331 | |||
| b202dd187f | |||
| 0314e587b6 | |||
| 9d7635d1e4 | |||
| 298e70b552 | |||
| 64c51647f0 | |||
| bc6b977585 | |||
| d06aae5461 | |||
| 4f941f8ba4 | |||
| 9a49833339 | |||
| 749cf6868c | |||
| a5f0b92ee9 | |||
| 6ec23b2de1 | |||
| 3298eb916c | |||
| 40430e787c | |||
| f434fa159f | |||
| 8ddf73ede3 | |||
| 844a15a3cb | |||
| 58f4c39d6f | |||
| 8a54cecd1d | |||
| 0e430e8077 | |||
| 26caa6024e | |||
| 8691a93ebf | |||
| c563f80934 | |||
| 27797d2392 | |||
| 6a4e57e5d3 | |||
| 29f94d89e1 | |||
| 12f6045da0 | |||
| 6eb80b06e3 | |||
| 86feb6d755 | |||
| 8bf49f6ea6 | |||
| f3d0fe6411 | |||
| cba3a0efb0 | |||
| b58cd75c98 | |||
| c530a989df | |||
| 2baf14d9a5 | |||
| 6903c9e51a | |||
| 31e10af9cb | |||
| 0d841d9d73 | |||
| 4bcefbc9d4 | |||
| 91db99b967 | |||
| 5747ec49c6 | |||
| d3a714a1cb | |||
| 7f8ad81065 | |||
| 75da54150f | |||
| 38fb315415 | |||
| 1c79ec3aa1 | |||
| 01e1b2fc2d | |||
| 15943e233c | |||
| f6a6b0184b | |||
| 898035d83a | |||
| de3666aed3 | |||
| c00811da2e | |||
| 55abcf8340 | |||
| 9d7c79cf0b | |||
| 24131617c7 | |||
| 05b67cbfcf | |||
| e433988962 | |||
| 05a28d1178 | |||
| 1e5bffc0e6 | |||
| f5a689b578 | |||
| e4abff4164 | |||
| 1a8f1cc3a5 | |||
| 9b99463187 | |||
| f69fa24b40 | |||
| 77d97296ea | |||
| 032679bf34 | |||
| 55a59de6db | |||
| a0d13962bd | |||
| 5f33e1e90c | |||
| b750b0fec6 | |||
| b498e5bee5 | |||
| ba577429e6 | |||
| 014b99504b | |||
| 263dd2f1bd | |||
| af43d6f438 | |||
| a4cbd6390e | |||
| 5ed0f55970 | |||
| 91ab37a39d | |||
| fbe9ae6d8b | |||
| 1aa121df71 | |||
| 6e40c166d6 | |||
| a56d4691ff | |||
| 2718fbb8fe | |||
| 2d52421919 | |||
| 167fcd475f | |||
| aa93aeda3f | |||
| 94381a67fd | |||
| c26b34147b | |||
| c9f9c98fbe | |||
| f6bf4454b0 | |||
| a7ca2c59a2 | |||
| 10c14aa794 | |||
| e9774b252b | |||
| 19fe621df1 | |||
| 82b289f4fe | |||
| 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 |
42
README.md
42
README.md
@@ -9,7 +9,7 @@ ConfigKit's simple `Load` accepts one strict YAML source (or explicit
|
||||
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
||||
source groups with an independently selected main input. Sources can be host
|
||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||
Enabled path and glob imports compose strictly within their group; precedence
|
||||
Enabled explicit imports compose strictly within their group; precedence
|
||||
between groups combines mappings recursively. Both entry points return a complete,
|
||||
validated configuration with detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
@@ -17,7 +17,8 @@ then report domain errors and warnings through read-only semantic validation. Se
|
||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||
references resolve environment names before validation, and explicit
|
||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||
CLI override records apply above overlays. Value explanation is available through
|
||||
CLI override records apply after inheritance and typed decoding, before application
|
||||
resolution. Value explanation is available through
|
||||
detached structured records and deterministic redacted output. Built-in
|
||||
`extends` relationships let a mapping inherit from a named sibling during the
|
||||
same load, retaining explicit presence and source/derivation history. The root
|
||||
@@ -26,6 +27,24 @@ the [configuration contract](docs/config.md#effective-configuration-diffs) and
|
||||
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
||||
its safe changed-region records and maintained workflows.
|
||||
|
||||
Sources can also be explicitly placed as sequence items. Configuration
|
||||
declarations and application registrations can give selected sequences scalar
|
||||
identities and choose how they combine across precedence and inheritance.
|
||||
Strict peers combine distinct records, while higher-priority collections can
|
||||
replace, append, or upsert whole records. See the
|
||||
[sequence contract](docs/config.md#strict-document-composition),
|
||||
[maintained catalog examples](docs/consumers/README.md#identified-prompt-sequences),
|
||||
and [ADR 0017](docs/adr/0017-collection-sequence-policy.md).
|
||||
|
||||
Imports are also explicit: a source may name files, search declared directories,
|
||||
or select files with path globs. Applications can set load-wide import defaults
|
||||
while keeping declaration-specific exceptions. Files are acquired in authored
|
||||
declaration order; dependency-ready composition uses a shared stable scheduler.
|
||||
ConfigKit does not search conventional application paths implicitly. See the
|
||||
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
|
||||
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@@ -60,19 +79,26 @@ mappings combine, while overlapping non-mapping definitions are errors. Between
|
||||
precedence tiers, mappings combine recursively, retaining omitted lower-priority
|
||||
children; other values replace lower-priority values. The main document and its
|
||||
imports form one strictly composed tier. Explicit underlay groups sit below it
|
||||
and above typed defaults; a selected overlay and explicit CLI overrides sit
|
||||
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
||||
owns the detailed combination rules and input-specific constraints.
|
||||
and above typed defaults; a selected overlay applies before inheritance, while
|
||||
explicit CLI overrides apply after decoding and before application resolution.
|
||||
The [configuration contract](docs/config.md#strict-document-composition) owns
|
||||
the detailed combination rules and input-specific constraints. Declared or
|
||||
registered collections combine distinct identified records within strict tiers
|
||||
and use their selected operation at precedence boundaries; other sequences keep
|
||||
their strict-conflict and whole-value-replacement behavior.
|
||||
|
||||
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
||||
these priorities and how genuine new needs can justify extending the shared
|
||||
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
|
||||
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
|
||||
consideration in future releases.
|
||||
|
||||
The [configuration contract](docs/config.md) describes implemented behavior,
|
||||
including [input destinations](docs/config.md#input-destinations). The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
||||
layered, and mounted-collection examples alongside the migration shapes. The
|
||||
accepted placement decision is recorded in [ADR 0011](docs/adr/0011-input-destinations.md).
|
||||
layered, mounted-collection, and identified-prompt examples alongside the
|
||||
migration shapes. Input placement decisions are recorded in
|
||||
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
|
||||
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
|
||||
See the [development guide](docs/development.md) for repository orientation and
|
||||
the [release procedure](docs/release.md) for maintainer-only candidate and
|
||||
publication steps.
|
||||
|
||||
@@ -43,11 +43,80 @@ func toEngineLocations(values []Location) []engine.Location {
|
||||
return result
|
||||
}
|
||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
||||
return SourceInfo{
|
||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
|
||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||
}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
||||
result := engine.SourceInfo{
|
||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination),
|
||||
Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||
ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck),
|
||||
}
|
||||
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
|
||||
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
||||
}
|
||||
|
||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ImportSelection, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = ImportSelection{
|
||||
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.ImportSelectionForFacade, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = engine.ImportSelectionForFacade{
|
||||
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
|
||||
Selector: string(value.Selector), Outcome: string(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||
return engine.ImportDefaults{
|
||||
Required: cloneImportDefaultBool(value.Required),
|
||||
Follow: cloneImportDefaultBool(value.Follow),
|
||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneImportDefaultBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneImportDefaultStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
||||
|
||||
Status: accepted
|
||||
Status: Partially superseded by
|
||||
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
|
||||
ordering. The remaining ownership and safe explanation decisions remain accepted.
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed draft for review. Completed planning and remediation records are
|
||||
Proposed draft for review; its whole-sequence precedence replacement rule is
|
||||
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
|
||||
retains its proposed status. Completed planning and remediation records are
|
||||
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
||||
owns current behavior. The completed implementation plan is preserved in Git
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
sequence combination and the addition of identified-sequence sibling scopes,
|
||||
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
|
||||
ordering. Existing mapping-sibling scopes and the remaining ownership and
|
||||
inspection decisions remain accepted.
|
||||
|
||||
The [configuration contract](../config.md#sibling-inheritance) owns implemented
|
||||
behavior. The completed implementation plan is preserved in Git
|
||||
history at `83c59ea`.
|
||||
|
||||
## Date
|
||||
|
||||
81
docs/adr/0014-sequence-placement-and-composition.md
Normal file
81
docs/adr/0014-sequence-placement-and-composition.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# ADR 0014: Place and Compose Sequence Items Explicitly
|
||||
|
||||
## Status
|
||||
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
identity ownership and sequence combination. Explicit source placement remains
|
||||
accepted.
|
||||
|
||||
The [configuration contract](../config.md#strict-document-composition)
|
||||
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
shows the maintained example. The completed roadmap, implementation plan, and
|
||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||
commands.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-16
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit strictly composes independent documents by recursively combining
|
||||
mappings and rejecting other overlapping values. This makes two independently
|
||||
authored sequence fragments conflict, even when their records are distinct.
|
||||
Precedence assembly has a different purpose: a higher-priority present sequence
|
||||
replaces the lower-priority sequence as one value. Those existing rules are
|
||||
predictable, but they do not express the separate operation of collecting
|
||||
independent records while rejecting duplicate identities.
|
||||
|
||||
Likewise, mapping destinations place a source beneath literal mapping keys, but
|
||||
there is no explicit way to state that a source mapping represents one item in a
|
||||
sequence. Inferring that intent from document shape or fields would make source
|
||||
meaning implicit and change existing placement behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep mapping placement as the zero-value behavior and add an explicit item
|
||||
placement choice for a source whose mapping should become one element at a
|
||||
non-root sequence destination. Item placement provides structure only; it does
|
||||
not infer or synthesize identity values.
|
||||
|
||||
Allow a definition to register literal mapping paths and one literal identity
|
||||
field for sequences at those paths. Within each strict composition tier, a
|
||||
registered sequence accepts mapping records with a present string identity,
|
||||
appends distinct records in contribution order, and rejects repeated exact
|
||||
identity strings. Identical registrations coalesce; conflicting identity fields
|
||||
for the same path are invalid. Registration does not recursively select paths
|
||||
inside sequence records.
|
||||
|
||||
Keep precedence assembly unchanged. A present higher-priority sequence replaces
|
||||
the lower-priority value wholesale, including at a registered path. Registration
|
||||
changes strict composition only; it does not turn precedence into append or
|
||||
identity-based merging.
|
||||
|
||||
This decision preserves ConfigKit's distinction between independent
|
||||
same-priority contributions and intentional precedence. Explicit source
|
||||
placement and definition-owned composition policy make the two required choices
|
||||
visible while keeping the existing zero-value load behavior.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Infer item placement from a source mapping or identity field.** Shape-based
|
||||
inference would make ordinary mapping placement depend on document contents
|
||||
and would leave the intended destination ambiguous.
|
||||
- **Append all overlapping sequences during strict composition.** This would
|
||||
change existing conflict semantics at unregistered paths and would not detect
|
||||
duplicate records.
|
||||
- **Merge matching records recursively by identity.** This would add a second
|
||||
recursive merge rule with different conflict and precedence behavior from
|
||||
ConfigKit's existing mapping composition and overlay operations.
|
||||
- **Use identity to merge sequences across precedence tiers.** This would
|
||||
change established whole-value replacement semantics and conflate strict
|
||||
composition with precedence.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can explicitly place mappings as sequence items and opt selected
|
||||
sequences into duplicate-rejecting strict composition. Unregistered sequences
|
||||
retain their current strict-conflict behavior, and higher-priority sequences
|
||||
continue to replace lower-priority ones. The implementation plan owns the exact
|
||||
API, YAML syntax, diagnostics, and implementation status.
|
||||
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0015: Share Dependency-Ready Ordering Across Imports and Inheritance
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented behavior is described in the
|
||||
[configuration contract](../config.md#explicit-imports) and
|
||||
[internal import overview](../internal/imports.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Imports and sibling inheritance both resolve relationships between configuration
|
||||
contributions. Their existing resolvers use different traversal rules, making
|
||||
ordering depend on which mechanism expresses a dependency. Ordering affects
|
||||
sequence contributions, provenance, and diagnostics, so a consistent rule is
|
||||
part of ConfigKit's configuration model.
|
||||
|
||||
The resolvers also serve distinct purposes. Imports strictly compose sources;
|
||||
inheritance overlays a child on its resolved parent after source assembly.
|
||||
Item-local contributions and nested inheritance introduce scope boundaries that
|
||||
resolution must preserve.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit owns one shared dependency-ready scheduler for imports and sibling
|
||||
inheritance. Both resolvers use Kahn's algorithm with a stable ordering key,
|
||||
selecting the smallest currently eligible node after its dependencies have
|
||||
completed. The shared scheduler owns eligibility, deterministic tie-breaking,
|
||||
and dependency-cycle identification. Each resolver owns construction of its
|
||||
dependency relationships and application of its domain operations.
|
||||
|
||||
Apply the common scheduling rule within the scopes where dependencies are
|
||||
available. Complete item-local contributions before item placement and resolve
|
||||
enclosing inheritance before relationships in the resulting nested scopes.
|
||||
Source discovery supplies the import graph; the scheduler determines resolution
|
||||
order.
|
||||
|
||||
Strict composition, inheritance overlay, and precedence tiers retain their
|
||||
existing ownership and meanings under [ADR 0001](0001-separate-composition-and-overlay.md).
|
||||
The lifecycle and scope decisions in
|
||||
[ADR 0009](0009-sibling-inheritance.md) continue to govern inheritance. Import
|
||||
admission retains its cycle and repeated-source rules.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Deterministic depth-first postorder.** Keeps dependency branches together,
|
||||
but gives traversal branch selection an additional role in ordering.
|
||||
- **Dependency rounds.** Groups nodes by readiness rounds, but delays newly
|
||||
eligible nodes until the current round finishes.
|
||||
- **Descending longest-path depth.** Prioritizes the furthest dependencies,
|
||||
but adds distance from the root as an ordering policy.
|
||||
- **Separate resolver algorithms.** Allows local specialization, but leaves
|
||||
consumers and maintainers with different rules for analogous dependencies.
|
||||
|
||||
## Consequences
|
||||
|
||||
Both resolvers follow one deterministic ordering model and share responsibility
|
||||
for maintaining that model. Resolver-specific scopes, combination semantics,
|
||||
and source-admission rules remain explicit at their integration boundaries.
|
||||
|
||||
Independent branches may interleave as nodes become eligible. Adopting this
|
||||
rule changes existing traversal-dependent sequence positions and reporting
|
||||
order, requiring coordinated review of source assembly, provenance, and
|
||||
inheritance behavior. The configuration contract owns the implemented ordering
|
||||
details.
|
||||
83
docs/adr/0016-independent-import-controls.md
Normal file
83
docs/adr/0016-independent-import-controls.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# ADR 0016: Separate Import Selection, Traversal, and Presence Requirements
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented behavior is described in the
|
||||
[configuration contract](../config.md#explicit-imports), the
|
||||
[public API declarations](../../lifecycle.go), and the
|
||||
[internal import overview](../internal/imports.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Consumers assemble configurations from named files and collections whose layout
|
||||
varies between installations. Simple imports should require little setup, while
|
||||
larger collections need control over discovery, recursion, filtering, and
|
||||
required contributions. Combining those choices implicitly makes declarations
|
||||
harder to interpret and pushes source-selection work into applications.
|
||||
|
||||
Discovery can also reach a candidate through overlapping searches or encounter
|
||||
different names for one canonical source. Selection and source admission need
|
||||
distinct identities to support useful search unions while preserving ConfigKit's
|
||||
existing source-integrity rules.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit owns import discovery and selection within its source-loading
|
||||
lifecycle. Support explicit files, directory searches, and path globs as
|
||||
distinct selection forms. Keep traversal, filename filtering, and presence
|
||||
requirements independently expressible. Discovery determines the selected
|
||||
contributions; the shared scheduler in
|
||||
[ADR 0015](0015-shared-dependency-ready-ordering.md) determines their resolution
|
||||
order.
|
||||
|
||||
Use optional-by-default imports with explicit requiredness controls. Selected
|
||||
sources remain subject to ConfigKit's validity, containment, resource, and
|
||||
composition contracts. This applies the library's
|
||||
[simplicity and defaults principles](../policy/architecture.md#simplicity-defaults-and-explicit-controls)
|
||||
to import absence while retaining meaningful errors for invalid contributions.
|
||||
|
||||
Applications own their selected import defaults. Authored declaration values
|
||||
take precedence over application defaults, which take precedence over library
|
||||
defaults. Each declaration resolves applicable controls independently,
|
||||
including declarations in imported sources. This gives applications a shared
|
||||
baseline while preserving explicit choices in individual declarations.
|
||||
|
||||
Keep declaration-local candidate identity separate from canonical source
|
||||
identity. Search unions coalesce repeated candidate paths within a source
|
||||
namespace before symlink resolution; filename filtering uses the selected or
|
||||
discovered name. Source admission continues to use canonical identity and the
|
||||
existing repeated-source rules. Distinct aliases retain their separate
|
||||
inclusion routes.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Use path globs for all discovery.** Offers one selection form, but makes
|
||||
collection traversal depend on path-pattern structure and complicates
|
||||
independently controlling recursion and filename filtering.
|
||||
- **Offer only explicit files and directory searches.** Reduces the selector
|
||||
vocabulary, but moves wildcard directory selection into repeated declarations
|
||||
or application-side discovery.
|
||||
- **Require every import by default.** Detects absent contributions immediately,
|
||||
but requires repeated opt-outs for installations with optional components.
|
||||
- **Make application settings override authored declarations.** Provides central
|
||||
enforcement, but prevents declarations from expressing local exceptions.
|
||||
- **Deduplicate selections globally by canonical source identity.** Coalesces
|
||||
aliases and separate inclusion routes, but changes the existing repeated-source
|
||||
contract and obscures independently authored contributions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can express simple imports and more selective collection searches
|
||||
through the same loading lifecycle. Applications choose defaults and validate
|
||||
their required effective configuration; ConfigKit owns consistent selection,
|
||||
source admission, composition, and inspection.
|
||||
|
||||
Optional absence can conceal misspelled selectors, so requiredness and
|
||||
inspection provide explicit ways to detect and explain missing contributions.
|
||||
Declaration-local deduplication requires the loader to preserve candidate
|
||||
identity alongside canonical source identity. The configuration and public API
|
||||
contracts own the implemented behavior.
|
||||
143
docs/adr/0017-collection-sequence-policy.md
Normal file
143
docs/adr/0017-collection-sequence-policy.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# ADR 0017: Define Identity and Merge Rules for Each Collection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
This supersedes the decisions about who declares identity and how
|
||||
sequences combine in [ADR 0014](0014-sequence-placement-and-composition.md),
|
||||
and the rule that inheritance always replaces sequences in
|
||||
[ADR 0009](0009-sibling-inheritance.md). It also extends that ADR's sibling
|
||||
scopes to identified sequences. Existing mapping-sibling scopes and source
|
||||
placement continue to apply. It supersedes the whole-sequence
|
||||
precedence replacement rule in the proposed
|
||||
[ADR 0007](0007-source-backed-underlays.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-22
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit can load separate files as records in a sequence. Applications can
|
||||
register an identity key so records with different identities combine and
|
||||
duplicates fail. However, a higher configuration layer or an inheriting mapping
|
||||
currently replaces the whole sequence. Applications that need to extend a
|
||||
catalog across layers must write their own merging code and keep source and
|
||||
history information consistent with its results.
|
||||
|
||||
Configuration authors need to choose how records are identified and whether a
|
||||
layer adds records, updates records, or replaces the collection. Applications
|
||||
also need a way to require unique identities in the final loaded configuration.
|
||||
These rules must work consistently for imported records, inline sequences,
|
||||
overlays, and inheritance.
|
||||
|
||||
Records in a sequence may also share settings. Authors need to reuse those
|
||||
settings through inheritance while keeping each record's own identity.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit manages identity and merge rules for each collection during loading.
|
||||
Configuration files can declare those rules for imported and inline sequences,
|
||||
including sequences in selected overlays. Applications can require a particular
|
||||
identity definition, and configuration declarations must agree with it. Source
|
||||
placement determines where each contribution enters the configuration.
|
||||
|
||||
Keep the distinction between contributions at the same priority and overrides
|
||||
from a higher layer, as established in
|
||||
[ADR 0001](0001-separate-composition-and-overlay.md). Within one tier, records
|
||||
with distinct identities combine; duplicate identities and conflicting
|
||||
declarations fail. Determine the applicable rules before combining those
|
||||
contributions, including any identity established in lower tiers.
|
||||
|
||||
Across layers, support three operations: replace the whole sequence, append
|
||||
records while rejecting duplicate identities, or replace whole records by
|
||||
identity (`upsert`). Source layering and sibling inheritance use the same
|
||||
operations. For inheritance, the rules at the inheriting destination determine
|
||||
which operation applies.
|
||||
|
||||
An identified sequence is also a sibling scope for inheritance. A record may
|
||||
inherit from one other record in the same sequence, selected by its complete
|
||||
identity. Each record supplies its own identity before inheritance. The child
|
||||
keeps that identity and combines its settings with the parent's other fields,
|
||||
using the existing mapping and collection rules. Parents remain ordinary records
|
||||
subject to normal validation.
|
||||
|
||||
Resolve these relationships after source assembly, using the same dependency
|
||||
ordering as mapping-sibling inheritance. Resolve nested relationships at each
|
||||
inheriting destination. Preserve the assembled sequence order. Whole-record
|
||||
upsert keeps its existing meaning; inheritance then uses the records that remain.
|
||||
Typed defaults, CLI overrides, and application resolution follow the lifecycle
|
||||
in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||
|
||||
An explicitly selected operation remains in effect at its destination until a
|
||||
higher layer changes it. When no explicit operation applies, use upsert if an
|
||||
identity definition is available; otherwise replace the whole sequence. Authors
|
||||
can declare identity and an operation before supplying any records. Keep source
|
||||
acquisition order separate from layer priority and the dependency ordering
|
||||
established by [ADR 0015](0015-shared-dependency-ready-ordering.md).
|
||||
|
||||
Rules declared inside a record belong to that record. Retaining the record
|
||||
retains its local rules; replacing it discards them. Replacing a whole collection
|
||||
can discard its previous YAML identity definition, while application requirements
|
||||
still apply. Inheritance can copy collection identity, but the inheriting
|
||||
destination chooses its operation. Application registrations continue to govern
|
||||
the paths where they were registered. The same ownership rules apply to
|
||||
declarations made before a sequence exists.
|
||||
|
||||
Check YAML-declared identity when combining collections during source assembly
|
||||
and inheritance. Application registrations also require the final loaded
|
||||
sequences to satisfy their identity rules, including after typed defaults, CLI
|
||||
overrides, and application resolution. This guarantee checks sequences that
|
||||
exist; the application schema and validation decide whether a collection must
|
||||
exist and which value types are allowed. The guarantee applies when loading
|
||||
completes, rather than to every intermediate callback state or changes made by
|
||||
the caller afterward.
|
||||
|
||||
Preserve explicit presence, source attribution, history, sensitivity, and
|
||||
resource context through these operations, following
|
||||
[ADR 0003](0003-preserve-presence-and-resolution-history.md). Typed defaults,
|
||||
CLI assignments, and application resolution keep their own rules for changing
|
||||
values. The public API provides registration and inspection; the implementation
|
||||
chooses how to store and track collection rules internally.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep identity application-owned and always replace sequences across layers.**
|
||||
Keeps the current design small, but reusable catalogs require application
|
||||
changes or custom merging code.
|
||||
- **Put merge rules on each import.** Keeps the rules beside the imported source,
|
||||
but inline sequences and overlays need a separate way to declare them. It also
|
||||
makes rules for a collection spread across several imports harder to reconcile.
|
||||
- **Merge individual fields of records with matching identities.** Allows partial
|
||||
record updates, but adds rules for field conflicts and retained values.
|
||||
Replacing the whole record makes the update's scope clear.
|
||||
- **Keep inheritance limited to mapping siblings.** Keeps parent lookup simpler,
|
||||
but authors of sequence records must duplicate shared settings or applications
|
||||
must implement inheritance themselves. Collection identity provides a way to
|
||||
select a parent within the existing loading process.
|
||||
- **Check every YAML identity again at the end of loading.** Gives YAML declarations
|
||||
authority over values changed by defaults, CLI assignments, and application
|
||||
resolution. This extends the lifetime of those declarations; application
|
||||
registration provides that final guarantee when needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can build and customize catalogs in one load while retaining useful
|
||||
inspection information. Configuration authors choose how collections combine
|
||||
within application requirements. Duplicate identities among contributions at
|
||||
the same priority remain errors.
|
||||
|
||||
Existing registered collections use upsert across layers by default unless an
|
||||
explicit operation selects otherwise. Correct loading now requires tracking
|
||||
which declarations still apply as records are replaced, copied, or moved to new
|
||||
positions. Lower-tier identity must be available before combining higher-tier
|
||||
contributions.
|
||||
|
||||
Record inheritance also requires parent lookup by identity and checking for
|
||||
missing parents and cycles. Copied settings retain the same source, history,
|
||||
and sensitivity information as mapping-sibling inheritance.
|
||||
|
||||
The [configuration contract](../config.md#strict-document-composition) defines
|
||||
declaration syntax, identity comparison, omission and empty-value behavior, and
|
||||
diagnostics for the implemented feature.
|
||||
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# ADR 0018: Apply CLI Overrides After Inheritance and Decoding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
This supersedes the rule that CLI overrides apply before decoding in
|
||||
[ADR 0005](0005-explicit-overrides-and-safe-explanation.md) and the
|
||||
rule that they apply before inheritance in [ADR 0009](0009-sibling-inheritance.md).
|
||||
Their remaining ownership, inheritance scope, and safe inspection decisions
|
||||
continue to apply.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-22
|
||||
|
||||
## Context
|
||||
|
||||
Explicit command-line values are the operator's highest-priority input. If they
|
||||
apply before inheritance, a sequence supplied as a replacement could gain
|
||||
inherited records afterward. Its final contents would then differ from the
|
||||
operator's assignment.
|
||||
|
||||
The application also needs these values when it fills missing settings,
|
||||
normalizes values, or computes derived settings. ConfigKit needs a clear loading
|
||||
order that preserves both CLI precedence and the application's role in resolving
|
||||
the final configuration.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit uses this loading order:
|
||||
|
||||
1. Combine sources within tiers and apply precedence between layers.
|
||||
2. Resolve sibling inheritance, apply typed defaults, and decode the configuration
|
||||
into the application's types.
|
||||
3. Apply explicit CLI overrides and check them against the application schema.
|
||||
4. Run application resolution using the overridden configuration.
|
||||
5. Look up effective secret references, validate the final configuration, and
|
||||
return it.
|
||||
|
||||
CLI overrides are the highest-priority input to application resolution. A CLI
|
||||
sequence replaces the whole sequence; mappings combine recursively. Collection
|
||||
merge rules apply during the earlier source and inheritance steps. Inheritance
|
||||
and typed defaulting do not run again after CLI overrides.
|
||||
|
||||
Keep track of which values were explicitly supplied and which CLI input supplied
|
||||
them. Applications continue to parse command-line arguments; ConfigKit applies
|
||||
and validates the overrides and makes their effects inspectable. Reject
|
||||
structural directives in override paths and values, so instructions such as
|
||||
inheritance relationships remain part of source processing.
|
||||
|
||||
Application resolution keeps its existing rules for defaults and recorded
|
||||
changes under [ADR 0003](0003-preserve-presence-and-resolution-history.md).
|
||||
It receives CLI values together with their presence and source information.
|
||||
Defaults that only fill missing settings preserve explicit CLI values.
|
||||
Application normalization and derived-value calculations can still change them,
|
||||
with those changes recorded. Secret lookup and final validation use the resulting
|
||||
configuration.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Apply CLI overrides before inheritance.** Preserves existing ordering, but
|
||||
later merging needs special handling to preserve CLI replacement intent while
|
||||
still combining ordinary source contributions.
|
||||
- **Apply CLI overrides after inheritance but before typed decoding.** Allows an
|
||||
override to fix a source value that would otherwise fail decoding, but leaves
|
||||
typed defaulting after the operator's assignment. Completing defaulting and
|
||||
decoding first gives CLI overrides an already resolved configuration to modify.
|
||||
- **Apply CLI overrides after application resolution.** Applies assignments after
|
||||
the application's changes, but derived settings still reflect earlier values
|
||||
unless resolution runs again.
|
||||
|
||||
## Consequences
|
||||
|
||||
CLI overrides change a configuration whose inheritance and typed defaults are
|
||||
already resolved. The application can then derive settings from the operator's
|
||||
values. The final values, source information, history, and sensitivity handling
|
||||
must remain consistent throughout loading.
|
||||
|
||||
CLI input can no longer add or cancel inheritance relationships or bypass an
|
||||
earlier error in source assembly or inheritance. It also cannot fix a value that
|
||||
fails the earlier decoding step. Semantic validation still runs after overrides
|
||||
and application resolution. The [configuration contract](../config.md#explicit-cli-overrides)
|
||||
and [consumer guidance](../consumers/README.md) describe this behavior.
|
||||
586
docs/config.md
586
docs/config.md
@@ -52,10 +52,11 @@ preserves already validated secret state without rereading the environment.
|
||||
The current lifecycle is:
|
||||
|
||||
```text
|
||||
validate options → parse the selected source → traverse/compose enabled imports
|
||||
validate options → parse the selected source → extract source controls and
|
||||
traverse/strictly compose enabled imports
|
||||
→ build schema → check the optional application version → apply a selected overlay
|
||||
→ apply explicit CLI overrides → expand sibling inheritance
|
||||
→ create and clone defaults → strictly decode
|
||||
→ expand sibling inheritance
|
||||
→ create and clone defaults → strictly decode → apply explicit CLI overrides
|
||||
→ recorded application resolution
|
||||
→ resolve typed secret references → semantic validation → classify secrets
|
||||
→ snapshot configuration and metadata
|
||||
@@ -67,13 +68,19 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
|
||||
contributes no source, and continues through defaults, decode, and projection. A
|
||||
missing file remains an error; it is never interpreted as no input.
|
||||
|
||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||
semantic validator, explicit path and glob imports, one selected overlay, and
|
||||
filesystem context. It also accepts explicit CLI override records, static secret
|
||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
||||
and filesystem context as invalid combinations but accepts overrides. Input
|
||||
containing a reserved `configkit` mapping key is rejected while imports are
|
||||
disabled.
|
||||
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||
`PlacementMode`, explicit imports, one selected overlay, filesystem
|
||||
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||
may register sequence identities through `Definition.SequenceIdentities`. Its
|
||||
zero-valued placement mode places the source mapping
|
||||
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
||||
non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
||||
source-root collection declarations whether or not imports are enabled; an
|
||||
`imports` declaration still requires imports to be enabled. `NoSource` rejects
|
||||
imports, active overlays, and filesystem context as invalid combinations but
|
||||
accepts overrides and remains an absent source rather than an empty document.
|
||||
|
||||
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
|
||||
@@ -123,16 +130,20 @@ nothing. Underlay groups are listed from lowest to highest priority. Repeating
|
||||
a file within one group remains an import/repetition error; the same reusable
|
||||
source may be selected again in another tier.
|
||||
|
||||
ConfigKit acquires and composes the main input first, constructs the schema,
|
||||
and checks the optional application version from the main tier only. Underlay
|
||||
roots cannot claim that version path (`underlay.version`); imported fragments
|
||||
retain `import.version` protection. It then acquires and composes underlay
|
||||
groups in order, recursively combines the groups, and applies the main tier,
|
||||
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
|
||||
definitions replace lower values between tiers; mappings combine recursively.
|
||||
Syntax and strict-composition errors inside a tier cannot be masked by a
|
||||
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
|
||||
validation, and publication run once on the final assembled input.
|
||||
ConfigKit acquires the main input first and constructs the schema. It then
|
||||
acquires and strictly composes underlay groups from lowest to highest priority,
|
||||
passing each group's accumulated lower collection policy into the next group.
|
||||
Each group is applied once as a precedence tier. The main tier is then composed
|
||||
with the accumulated underlay policy, and the optional application version is
|
||||
checked against that composed main tier only. Underlay roots cannot claim that
|
||||
version path (`underlay.version`); imported fragments retain `import.version`
|
||||
protection. ConfigKit then applies the selected overlay and resolves inheritance.
|
||||
Scalar, sequence, and null definitions replace lower values between tiers unless
|
||||
a collection policy defines the sequence operation; mappings combine recursively.
|
||||
After successful defaults and typed decoding, CLI overrides are applied to that
|
||||
typed configuration before application resolution. Syntax, strict-composition,
|
||||
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
||||
secret lookup, validation, and publication run once on the resulting values.
|
||||
|
||||
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
||||
independent of the main source's namespace. Its name and root-relative lookup
|
||||
@@ -142,13 +153,21 @@ Ordinary `Load` continues to infer its selected-overlay context from its source.
|
||||
|
||||
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||
history follows precedence order. Source `Layer` describes precedence while
|
||||
history follows precedence order. Default main-version checking happens after
|
||||
underlay composition because the main tier inherits the accumulated collection
|
||||
policy. Enabled per-source version checks retain acquisition order and source
|
||||
local headers. Source `Layer` describes precedence while
|
||||
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
|
||||
one-based `UnderlayIndex` giving their original tier position; all other records
|
||||
have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
||||
Byte, source, node, and glob-entry budgets are shared across the load, while
|
||||
the existing per-source and import-depth bounds still apply.
|
||||
|
||||
When a source mapping is explicitly placed as an item, its effective destination
|
||||
is the sequence container. After assembly assigns an index, nested source
|
||||
destinations are projected through that item. Physical source locations and
|
||||
resource contexts continue to identify the declaring source.
|
||||
|
||||
#### Input destinations
|
||||
|
||||
`Input.Destination` places the source's application mapping beneath a path
|
||||
@@ -176,12 +195,21 @@ follow the same strict composition within a tier and recursive precedence
|
||||
behavior described above. A selected overlay and CLI overrides remain rooted at
|
||||
the application root and are not moved beneath the main input's destination.
|
||||
|
||||
`SourceInfo.Destination` reports the placement for each acquired input source,
|
||||
including imports. Placement does not change source IDs, acquisition identity,
|
||||
physical source locations, or passive resource contexts. Introduced ancestor
|
||||
mappings use the contributing document's root location as a source fallback;
|
||||
authored content retains its original source coordinates. Returned source
|
||||
records are detached copies.
|
||||
`SourceInfo.Destination` reports each acquired source's effective mapping or
|
||||
sequence-container path, including imports. `PlacementMode` reports how that
|
||||
source was placed; mapping is the zero value, and CLI/overlay sources report
|
||||
mapping unless item placement was applied. `DestinationUnresolved` is true only
|
||||
when an enclosing item index needed to name the source's destination has not yet
|
||||
been assigned. In that case `Destination` reports the nearest resolved enclosing
|
||||
collection container and omits the unresolved local suffix. A source's own item
|
||||
placement still has an exact destination at its sequence container. Completed
|
||||
loads project nested source destinations through their owning items. Callback and
|
||||
failed-load snapshots preserve the evidence available at that point; a fallback
|
||||
snapshot remains unresolved, while a later exact snapshot is false. Returned
|
||||
source records and paths are detached. Placement does not change source IDs,
|
||||
acquisition identity, physical source locations, or passive resource contexts.
|
||||
Introduced ancestor mappings use the contributing document's root location as a
|
||||
source fallback; authored content retains its original source coordinates.
|
||||
|
||||
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||
@@ -194,28 +222,55 @@ is recorded in [ADR 0011](adr/0011-input-destinations.md) and
|
||||
|
||||
Any input mapping may declare `extends: name` to inherit from the mapping named
|
||||
`name` among its immediate siblings. Names are exact keys, including the empty
|
||||
string; lookup does not search ancestor or unrelated scopes. The document root
|
||||
and a sequence-element mapping have no sibling namespace for their own string
|
||||
relationship, though mappings nested inside them can have one. An `extends`
|
||||
string; lookup does not search ancestor or unrelated scopes. A mapping inside a
|
||||
sequence can also participate in record inheritance when that sequence has an
|
||||
effective identity declaration from `configkit.sequences` or an application
|
||||
registration. In that case, `extends` is a mapping containing exactly the
|
||||
sequence's identity fields, with scalar values; lookup is limited to that final
|
||||
sequence. The document root and a sequence-element mapping without an effective
|
||||
identity declaration have no sibling namespace for non-null string
|
||||
relationships, though mappings nested inside them can have one. An `extends`
|
||||
value of null cancels a relationship without looking up a parent. Other value
|
||||
types, missing or non-mapping parents, and cycles fail the load with value-free
|
||||
diagnostics.
|
||||
types, incomplete or extra reference fields, missing parents, and cycles fail
|
||||
the load with value-free diagnostics.
|
||||
|
||||
Within each mapping sibling scope or identified sequence, direct definitions
|
||||
resolve by dependency readiness. A parent is resolved before a child; among
|
||||
currently eligible definitions, the lexically smallest canonical semantic
|
||||
pointer is selected, including newly eligible definitions as soon as their
|
||||
parent completes. This ordering governs inheritance operations and their
|
||||
history, controls, and transfers. It does not reorder mappings or sequence
|
||||
records in the resulting configuration.
|
||||
|
||||
`extends` is reserved structural input across ordinary and layered loads, every
|
||||
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||
consumed before typed decoding and need not appear in the application's Go
|
||||
types. Applications previously using `extends` as an ordinary YAML field must
|
||||
rename that key and its field. No collection registration or keyword setup is
|
||||
required.
|
||||
required for mapping-sibling relationships; identified-sequence relationships
|
||||
require effective identity fields for that collection.
|
||||
|
||||
ConfigKit completes strict composition within tiers and precedence assembly
|
||||
between tiers before resolving relationships. An omitted `extends` retains a
|
||||
lower-priority relationship; `extends: null` cancels it while preserving the
|
||||
child's own settings. Superseded references are historical evidence, not active
|
||||
relationships to validate. Each child recursively overlays its own settings
|
||||
over its resolved parent: explicit zero, false, empty string, sequence, and
|
||||
null values replace inherited values, while mappings combine recursively.
|
||||
The child retains its key. All effective relationships resolve, including
|
||||
between tiers before resolving relationships. Only records that survive that
|
||||
assembly participate in identified-sequence lookup: an upserted parent supplies
|
||||
its final record, a replaced parent is unavailable, and a higher replacement
|
||||
without `extends` supersedes a lower relationship. An omitted `extends` retains
|
||||
a lower-priority mapping-sibling relationship; an identified record's effective
|
||||
directive depends on the record that survives assembly. `extends: null` cancels
|
||||
a relationship while preserving the child's own settings. Superseded references
|
||||
are historical evidence, not active relationships to validate. Each child
|
||||
recursively overlays its own settings over its resolved parent: explicit zero,
|
||||
false, empty string, and
|
||||
null values replace inherited values, while mappings combine recursively. For
|
||||
identified records, the child's identity fields and their origins remain its
|
||||
own; parent identity fields do not contribute to the payload.
|
||||
Sequences at collection destinations use the shared collection operation with
|
||||
the parent as lower input and the child as higher input. The destination's
|
||||
explicit mode applies; otherwise a copied identity selects upsert and an
|
||||
unkeyed destination replaces. A parent's explicit mode does not move to a
|
||||
different destination. Sequences outside collection destinations remain atomic
|
||||
replacements.
|
||||
All effective relationships resolve, including
|
||||
entries the application may never select for execution. Partial parent
|
||||
definitions undergo normal typed decoding and application-owned validation,
|
||||
not a special structural exemption.
|
||||
@@ -397,15 +452,22 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
|
||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||
value for absent input remains non-explicit.
|
||||
|
||||
Strict composition assigns contributed records their assembled sequence positions
|
||||
in history. Across tiers, history follows the selected
|
||||
[collection operation](#strict-document-composition), including append and
|
||||
whole-record upsert. Incoming contributions use their assigned positions, and
|
||||
replaced payload remains available as historical evidence.
|
||||
|
||||
A mapping assembled from the selected file and imports has one composite base origin
|
||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||
has an overlay-layer composite origin with the lower contributing locations followed
|
||||
by the overlay location; an empty mapping is still an explicit container even though
|
||||
it contributes no children. Resolution origins are synthetic: they have layer
|
||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||
composite origin with the lower contributing locations followed by the overlay
|
||||
location; an empty mapping is still an explicit container even though it contributes
|
||||
no children. Resolution origins are synthetic: they have layer
|
||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||
selected overlay, then synthetic CLI records in supplied order.
|
||||
removed. Source records and IDs retain [acquisition order](#explicit-imports),
|
||||
including the underlay ordering described in [layered loading](#layered-loading).
|
||||
|
||||
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||
lists concrete transfers from contributor paths through destinations, with
|
||||
@@ -468,29 +530,34 @@ historical path. Its effective value and each chronological before/after value a
|
||||
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||
resolution operations. Structural inheritance events appear after source replay
|
||||
and before typed reconciliation, including consumed or canceled `extends` input
|
||||
and equal-valued child replacements. Overlapping parent/child resolution records
|
||||
application-default or Go-zero baseline, followed by relevant underlay and main-tier
|
||||
contributions in precedence order, the selected overlay, and structural inheritance
|
||||
events. Typed defaulting and decoding reconcile that source history before supplied
|
||||
CLI records and audited net resolution operations. Within each strict tier, replay follows
|
||||
[scoped assembly order](#explicit-imports), not source-record acquisition order.
|
||||
Inheritance events include consumed or canceled `extends` input and equal-valued
|
||||
child replacements. Overlapping parent/child resolution records
|
||||
are rendered
|
||||
as one net subtree event with combined reasons and dependencies, rather than
|
||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||
writes remain visible. When input replay
|
||||
differs from the final typed input, a `ChangeDecoding` event records the transition
|
||||
before domain resolution. Its origin retains the decoded path's source/default
|
||||
attribution; its reason identifies static-default retention and canonical decoding.
|
||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||
writes remain visible. When structural replay differs from the pre-CLI typed input,
|
||||
a `ChangeDecoding` event records retained defaults and canonical decoding. CLI
|
||||
contributions then retain their raw input representation while effective and
|
||||
resolution values use the typed projection. This built-in inspection operation is
|
||||
not accepted by `Resolution.Record`.
|
||||
|
||||
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||
including a superseded value that could not have decoded into the final Go field.
|
||||
Baseline, effective, and resolution values use canonical typed projection. A
|
||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
|
||||
queries reconstruct each source or override contribution, while sequences remain
|
||||
atomic: a descendant history identifies its presence before and after the containing
|
||||
sequence replacement without implying element-wise merging. Removed paths remain
|
||||
explainable; unknown and malformed paths return false.
|
||||
queries reconstruct each source or override contribution. A sequence snapshot
|
||||
represents the collection at that point in replay, and sequence diffs remain
|
||||
atomic. Descendant queries describe their presence and values before and after
|
||||
containing sequence operations. Combination follows the
|
||||
[collection operation tables](#strict-document-composition); upsert replaces
|
||||
matching records in full. Removed paths remain explainable; unknown and malformed
|
||||
paths return false.
|
||||
|
||||
Every snapshot applies the same historical tag, predicate, and recorded-dependency
|
||||
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
|
||||
@@ -577,13 +644,15 @@ must resolve to a nonempty value. Secret references work in typed structs, maps,
|
||||
pointers, slices, and arrays. A dynamic `any` value does not establish secret
|
||||
semantics and cannot contain `Secret`; applications must use a typed slot.
|
||||
|
||||
ConfigKit resolves only the effective names after recorded application resolution
|
||||
and before semantic validation. Each unique name is read with `os.LookupEnv` once
|
||||
per load. Validation can inspect the credential through the deliberately named
|
||||
`Value()` accessor and distinguish success with `IsResolved()`. Later loads read
|
||||
the environment again, and cloned defaults are rebuilt from names so a previously
|
||||
resolved result cannot retain a stale credential. `Load` never writes to the
|
||||
environment.
|
||||
ConfigKit resolves only the effective names after CLI assignment and recorded
|
||||
application resolution, and before semantic validation. CLI overrides may supply
|
||||
or replace a typed secret reference; lookup uses that effective name and retains
|
||||
its effective source location for diagnostics. Each unique name is read with
|
||||
`os.LookupEnv` once per load. Validation can inspect the credential through the
|
||||
deliberately named `Value()` accessor and distinguish success with `IsResolved()`.
|
||||
Later loads read the environment again, and cloned defaults are rebuilt from names
|
||||
so a previously resolved result cannot retain a stale credential. `Load` never
|
||||
writes to the environment.
|
||||
|
||||
Credential bytes are held only in the per-load lookup state and the trusted returned
|
||||
configuration, and are returned only by `Value`. Secret formatting uses a safe
|
||||
@@ -647,18 +716,19 @@ files when appropriate; ConfigKit stores no implicit global report.
|
||||
|
||||
### Recorded application resolution
|
||||
|
||||
An optional `Definition.Resolve` callback runs after successful typed decoding. It
|
||||
receives the working configuration and a per-load `Resolution` context. The context's
|
||||
An optional `Definition.Resolve` callback runs after successful defaults, typed
|
||||
decoding, and explicit CLI assignment. It receives the working configuration and a
|
||||
per-load `Resolution` context. The context's
|
||||
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||
null are never confused with absence or static defaults.
|
||||
|
||||
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
|
||||
decoded entry state, before application resolution runs. It includes retained static
|
||||
defaults, preserves explicit null, and uses the same exact semantic path matching as
|
||||
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
|
||||
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
|
||||
found entry path. Resolution changes, additions, removals, and moves do not alter
|
||||
this entry view.
|
||||
post-decode entry state, including explicit CLI overrides, before application
|
||||
resolution runs. It includes retained static defaults, preserves explicit null, and
|
||||
uses the same exact semantic path matching as `Metadata.Lookup`; missing,
|
||||
malformed, and key/index-shape-mismatched paths return false without diagnostics.
|
||||
Its `Explicit` value agrees with `IsExplicit` for every found entry path. Resolution
|
||||
changes, additions, removals, and moves do not alter this entry view.
|
||||
|
||||
`Resolution.Source(id)` returns a detached record for an already acquired source in
|
||||
the same load: the main source, imports, selected overlay, and supplied CLI records
|
||||
@@ -733,8 +803,11 @@ live services as a substitute for configuration validation.
|
||||
|
||||
## Strict document composition
|
||||
|
||||
ConfigKit strictly composes parsed base documents when `Options.Imports` is true.
|
||||
Application defaults do not participate in this operation.
|
||||
Ordinary `Load` applies strict document composition to its parsed source scope;
|
||||
`Options.Imports` adds imported documents as peers in that scope. Application
|
||||
defaults do not participate. With imports disabled, the single source still has
|
||||
its controls extracted and any declared or registered collection identity is
|
||||
validated, but there are no imported peers to combine.
|
||||
|
||||
| Existing node | Incoming node | Result |
|
||||
| --- | --- | --- |
|
||||
@@ -744,20 +817,148 @@ Application defaults do not participate in this operation.
|
||||
| Mapping | Mapping | Recurse by mapping key |
|
||||
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
||||
|
||||
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
|
||||
sequences are present atomic values, so they also conflict when repeated; sequences
|
||||
are never joined by index. Disjoint entries under shared mappings compose, making
|
||||
stable keyed mappings the natural representation for independently contributed
|
||||
entries.
|
||||
Every non-mapping overlap is a conflict, even when values are equal, except for
|
||||
sequence/sequence contributions at a destination with effective identity from an
|
||||
application registration, a current YAML `key` declaration, or retained lower
|
||||
policy. Nulls remain atomic conflicts.
|
||||
Sequences without effective identity conflict when repeated and are never joined
|
||||
by index. Disjoint entries under shared mappings compose, making stable keyed
|
||||
mappings the natural representation for independently contributed entries.
|
||||
|
||||
`Definition.SequenceIdentities` contains registrations created by
|
||||
`SequenceIdentityAt` or `SequenceIdentityMatching`. The exact form accepts a
|
||||
mapping-only `Path`; the matching form accepts literal-key and any-mapping-key
|
||||
segments created by `SequenceLiteralKey` and `SequenceAnyMappingKey`. A literal
|
||||
`"*"` key is distinct from the wildcard constructor. Parsed numeric path tokens
|
||||
remain literal mapping keys; explicit index segments and reserved `configkit` or
|
||||
mapping-valued `extends` segments are invalid. The root selector is harmless under
|
||||
the mapping-root schema. Selector segments consume one mapping level and match a
|
||||
complete destination; an any-mapping-key segment matches exactly one key, not an
|
||||
arbitrary prefix or suffix. Each registration requires one or more literal,
|
||||
direct field names; compound field sets are normalized as sets, and field names
|
||||
are not otherwise normalized. The same field-set and scalar-equality rules apply
|
||||
to YAML `key` declarations.
|
||||
|
||||
At each selected path, records must provide every effective identity field as a
|
||||
scalar string, number, boolean, or explicit null. Field names form a set, so
|
||||
order and repetition do not affect compound identity. The type-tagged tuple
|
||||
preserves category distinctions: strings compare exactly, including empty
|
||||
strings; numbers, booleans, and null remain distinct categories. Finite numbers
|
||||
compare by exact mathematical value, so `1`, `1.0`, and `1e0` are equal, as are
|
||||
`0` and `-0`, without floating-point rounding. Distinct tuples append in source
|
||||
and list order; duplicate tuples conflict even when records are otherwise equal
|
||||
or disjoint. Malformed records produce `compose.invalid`; duplicate identities
|
||||
produce `compose.conflict`, with paths and source locations identifying a
|
||||
duplicate without exposing identity values. Missing paths and non-sequence values
|
||||
do not fail by themselves. Equivalent overlapping registrations coalesce.
|
||||
Incompatible overlaps and invalid registrations produce `options.invalid` before
|
||||
source acquisition. Unused and root registrations are allowed and have no effect.
|
||||
|
||||
Registrations provide identity for strict composition, sequence operations,
|
||||
inheritance lookup, and final validation. Dynamic selectors apply at concrete
|
||||
strict-composition, inheritance, and final-result destinations without requiring
|
||||
the application to enumerate mapping keys. Each strict tier has independent
|
||||
duplicate-identity state among its peers; layered precedence and inheritance
|
||||
then apply the collection operation described below.
|
||||
|
||||
Final registration checks run in both ordinary and layered loads after effective
|
||||
secret lookup and before application semantic validation. They examine actual
|
||||
final typed sequences, including values introduced by defaults, CLI, or recorded
|
||||
resolution; they do not require path existence or sequence shape. Diagnostics
|
||||
use concrete collection and item paths and available source locations, without
|
||||
exposing identity or credential values. The guarantee ends when loading publishes
|
||||
successfully and does not cover later caller mutations.
|
||||
YAML-only identity is enforced during structural composition, precedence, and
|
||||
identified inheritance; it carries no final-result guarantee after typed
|
||||
defaults, CLI, or application resolution. Applications that need that guarantee
|
||||
register the identity constraint.
|
||||
The [maintained catalog examples](consumers/README.md#identified-prompt-sequences)
|
||||
show strict peers, precedence operations, inheritance, dynamic destinations, and
|
||||
the distinction between YAML-only declarations and application registrations.
|
||||
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.
|
||||
|
||||
Ordinary sources, layered inputs, and selected overlays may declare collection
|
||||
behavior under `configkit.sequences` at the document root. Each entry has a
|
||||
required literal `destination` and may specify `key` as one string or a nonempty
|
||||
list of strings, plus a `mode` of `replace`, `append`, or `upsert`. These
|
||||
controls are removed before application decoding. A declaration destination is
|
||||
relative to that source's placement, so a mounted fragment's declaration is
|
||||
mounted with its values. Declarations from strict peers must agree on identity
|
||||
fields and on any explicitly supplied mode; omission expresses no mode
|
||||
preference. They apply even when the declaring source has no value at that
|
||||
destination: a key validates an existing retained sequence or remains pending
|
||||
until records arrive, while a mode-only declaration affects later combinations
|
||||
without recombining earlier tiers. A key supplies the identity used by `append`
|
||||
and `upsert` and is checked against any application registration.
|
||||
|
||||
Strict peers at an identified destination first combine distinct records in
|
||||
source and list order. Duplicate identities among peers or within one
|
||||
contribution fail under every mode. After strict composition, precedence and
|
||||
inheritance use one operation for the assembled incoming collection:
|
||||
|
||||
| Mode | Behavior at source-precedence and inheritance boundaries |
|
||||
| --- | --- |
|
||||
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
|
||||
| `append` | Retain lower records and append higher records; duplicate identities fail. |
|
||||
| `upsert` | Replace matching records in full at their lower positions, retain unmatched lower records, and append new identities. |
|
||||
|
||||
Upsert replaces the complete matched record; it does not recursively merge
|
||||
record fields. Append and upsert require identity. With no explicit mode in the
|
||||
current tier, an explicit lower mode persists; if none exists, effective identity
|
||||
selects upsert and its absence selects replace. Inferred modes do not persist.
|
||||
Explicit modes persist at their destination until changed by a higher explicit
|
||||
mode, independently of YAML identity lifetime. A declaration alone does not
|
||||
clear or recombine a collection.
|
||||
|
||||
The presence and value kind at a precedence or inheritance boundary determine
|
||||
whether a collection changes:
|
||||
|
||||
| Higher contribution | `replace` | `append` / `upsert` |
|
||||
| --- | --- | --- |
|
||||
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
|
||||
| Explicit `[]` | Clear the sequence. | Contribute no records. |
|
||||
| Explicit null or another non-sequence value | Replace the lower value, subject to application schema. | Error; the operation requires a sequence. |
|
||||
|
||||
An absent lower value can be initialized by an incoming sequence under any mode.
|
||||
A present lower null or other non-sequence value is not absence: append and
|
||||
upsert cannot combine with it. An empty sequence still requires a valid mode and
|
||||
identity where applicable. Null used as an identity component inside a record is
|
||||
distinct from null used as the collection value.
|
||||
|
||||
Declarations govern ordinary strict source composition, including loads with
|
||||
imports disabled, and each strict scope in layered loading. Before composing a
|
||||
higher tier's peers, ConfigKit makes applicable lower identity and explicit mode
|
||||
available; after peer composition, the tier applies its resolved operation
|
||||
against the lower collection. Selected overlays use the same collection policy
|
||||
after the base source graph is composed. During mapping-sibling inheritance, a
|
||||
parent's YAML identity can follow its collection to the child destination, while
|
||||
the parent's explicit operation stays at the parent destination. The child
|
||||
destination selects its operation; absent one, effective identity implies
|
||||
upsert and its absence implies replace. Whole-sequence replacement discards
|
||||
lower YAML identity and uses the replacing scope's key declaration, if any;
|
||||
without one, the collection is unkeyed unless an application registration still
|
||||
supplies identity. Replacement discards policies owned by removed records, while
|
||||
an independently declared explicit mode and application registration remain
|
||||
applicable. Item-local
|
||||
policies follow their owning records: retaining a record retains its rules, while
|
||||
whole-record upsert replacement discards the displaced record's rules. Matching
|
||||
identity or reusing an index does not transfer them. These policies do not change
|
||||
CLI behavior: CLI sequence assignments replace whole sequences after decoding.
|
||||
A root `configkit.imports` declaration still requires `Options.Imports`;
|
||||
`NoSource` has no declaration scope.
|
||||
|
||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||
Shared mapping containers retain all contributing value locations as their composite
|
||||
base origin. A conflict reports the first claim as its primary location and every
|
||||
remaining claim as related locations, in deterministic source and declaration
|
||||
traversal order. Traversal order never authorizes replacement. Composition clones
|
||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||
input document.
|
||||
base origin. A conflict reports the first claim in
|
||||
[scoped assembly order](#explicit-imports) as its primary location and every
|
||||
remaining claim as related locations in the same order. Assembly order never
|
||||
authorizes replacement. Composition clones the resulting tree and its ordered
|
||||
claim records rather than mutating or aliasing an input document.
|
||||
|
||||
## Selected overlays
|
||||
|
||||
@@ -774,7 +975,9 @@ and typed decoding. Overlay bytes, nodes, and source records count toward the sa
|
||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||
root and must not duplicate a file already present in the base graph.
|
||||
|
||||
Overlay is intentionally distinct from strict composition:
|
||||
Collection destinations follow the
|
||||
[collection operation and presence rules](#strict-document-composition) above.
|
||||
The following table describes ordinary overlay behavior elsewhere:
|
||||
|
||||
| Existing lower node | Incoming overlay node | Result |
|
||||
| --- | --- | --- |
|
||||
@@ -783,18 +986,24 @@ Overlay is intentionally distinct from strict composition:
|
||||
| Mapping | Mapping | Recurse by mapping key |
|
||||
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||
|
||||
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
|
||||
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
||||
cross-kind replacement is allowed when the final application schema accepts it.
|
||||
Under these ordinary rules, scalars, sequences, and null are atomic. An overlay
|
||||
sequence replaces rather than extends a base sequence, `[]` clears it, null is an
|
||||
explicit replacement, and cross-kind replacement is allowed when the final
|
||||
application schema accepts it.
|
||||
Replacing a subtree removes all displaced descendants from effective presence and
|
||||
origin data; history retains the displaced definitions and identifies the parent
|
||||
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
||||
mapping containers retain their contributing locations.
|
||||
|
||||
An overlay may not contain `configkit` anywhere, contribute the configured schema
|
||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||
apply even if the supplied version text would be unchanged. Overlays cannot mask a
|
||||
base parse, composition, or version failure because those checks run first.
|
||||
An overlay may contain only root `configkit.sequences` declarations; overlay
|
||||
imports and nested directives are rejected. Sequence declarations are removed
|
||||
before application decoding. At declared or registered sequence destinations,
|
||||
`replace`, `append`, and `upsert` use the same identity and policy rules as source
|
||||
composition. Other overlay sequences remain atomic replacements. An overlay may
|
||||
not contribute the configured schema version or replace an ancestor of that
|
||||
version with a scalar or null. These rules apply even if the supplied version
|
||||
text would be unchanged. Overlays cannot mask a base parse, composition, or
|
||||
version failure because those checks run first.
|
||||
|
||||
## Explicit CLI overrides
|
||||
|
||||
@@ -806,10 +1015,16 @@ must never contain the raw argument or configured value. General environment
|
||||
variable overlays are an explicit non-goal: environment access is reserved for
|
||||
typed secret references.
|
||||
|
||||
Overrides apply after the selected overlay and before typed defaults/decoding, so
|
||||
the input precedence is application defaults < composed base < selected overlay <
|
||||
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
||||
effective override tree. Overrides also work with `NoSource`.
|
||||
Overrides are prepared after source assembly and inheritance, then assigned after
|
||||
typed defaults and decoding and before application resolution. The order is:
|
||||
defaults and decoded sources < CLI < application resolution. A CLI mapping
|
||||
recursively overlays the decoded mapping; a CLI sequence replaces the complete
|
||||
decoded sequence regardless of source collection operations. Inheritance and
|
||||
typed defaulting do not run again after CLI assignment. Earlier parsing,
|
||||
composition, inheritance, and decoding failures remain errors even if an override
|
||||
would replace the failing value. Recorded resolution, typed secret lookup, and
|
||||
semantic validation use the effective post-override values. Overrides also work
|
||||
with `NoSource`.
|
||||
|
||||
Values are converted directly into ConfigKit's owned tree without YAML parsing or
|
||||
string coercion. Supported values are `nil`, built-in strings and booleans, all
|
||||
@@ -820,7 +1035,8 @@ false, and zero remain intentional; empty sequences replace whole lists; and map
|
||||
values recursively overlay existing mappings. Map keys are sorted canonically and
|
||||
sequence order is preserved.
|
||||
|
||||
All records are detached and validated before any is applied. Root and explicit
|
||||
All records are detached and validated before any is applied. They cannot create
|
||||
`configkit` or `extends` controls. Root and explicit
|
||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
||||
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||
@@ -837,8 +1053,12 @@ unsupported types, nonfinite floats, and limit excesses fail with safe `override
|
||||
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
||||
arguments or values.
|
||||
|
||||
Override origins are explicit CLI origins. History retains replaced base/overlay
|
||||
definitions and records equal-value replacements as well as structural changes.
|
||||
Override origins are explicit CLI origins. Resolution receives CLI values with
|
||||
their explicit-presence and source information. Absence-based defaults preserve
|
||||
those values, while recorded normalization and derivation may change them. History
|
||||
retains replaced source definitions and records equal-value replacements as well
|
||||
as structural changes. Secret lookup and validation use post-resolution values
|
||||
with their effective source locations.
|
||||
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
||||
concurrently with the call. The executable `ExampleOverride` shows translating a
|
||||
parser's explicit-set state into records while preserving an explicitly supplied
|
||||
@@ -846,16 +1066,22 @@ zero.
|
||||
|
||||
## Explicit imports
|
||||
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||
string `path` or `glob`, an optional boolean `optional`, and an optional
|
||||
`destination` sequence of strings. Every destination string is one literal
|
||||
mapping key and follows the input-destination validation rules above; quote
|
||||
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
|
||||
and non-string members are `import.invalid`, as are all other unsupported shapes
|
||||
and fields. A `configkit` key below the root is invalid. With imports disabled,
|
||||
any occurrence remains `import.disabled`.
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may use its root
|
||||
`configkit` mapping for `imports` and `sequences` controls. Ordinary `Load` may use
|
||||
`sequences` without enabling imports, but an `imports` field still requires
|
||||
`Options.Imports`. ConfigKit removes recognized root controls before application
|
||||
decoding. Each imports sequence item is a mapping with exactly one `path`,
|
||||
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
|
||||
`glob` accepts one path pattern string. The optional `required` boolean controls
|
||||
whether an empty selection or a missing named path is an error. Its default is
|
||||
false, so missing imports are absent unless `required: true` is specified. The
|
||||
removed `optional` field is invalid. `destination` is an optional sequence of
|
||||
strings. Every destination string is one literal mapping key and follows the
|
||||
input-destination validation rules above; quote numeric-looking keys so YAML
|
||||
reads them as strings. Null, scalar destinations, and non-string members are
|
||||
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
|
||||
key below the root is invalid. With imports disabled, root imports and nested
|
||||
`configkit` keys remain `import.disabled`.
|
||||
|
||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||
@@ -864,25 +1090,118 @@ directive keys in root files and imported fragments, and rename any application
|
||||
fields previously named `configkit` to avoid the newly reserved key.
|
||||
|
||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||
and files are traversed depth-first in declaration order: a file contributes its own
|
||||
body before its imports. Order stabilizes source records and diagnostics but never
|
||||
permits replacement. Required missing paths fail; `optional: true` suppresses only
|
||||
target nonexistence, not invalid paths, containment failures, directories,
|
||||
permissions, parse errors, or composition errors.
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory.
|
||||
An empty `path: []` selects nothing; it succeeds even when `required: true` because
|
||||
there are no named targets to be missing. A nonempty required path list verifies
|
||||
every named target, including targets later excluded from selection.
|
||||
|
||||
Directory declarations search the listed literal directory roots. Missing roots
|
||||
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
|
||||
accepts one basename pattern or a list, matching filenames only. Omission selects
|
||||
all filenames; an explicit empty list selects none. `exclude` accepts one basename
|
||||
pattern or a list and removes matching final candidates. Patterns use Go's
|
||||
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
|
||||
`depth` is valid only when recursion is effective, with direct files at depth 1 and
|
||||
depth 0 or omission meaning unlimited traversal subject to library budgets.
|
||||
`follow` controls discovered symlink entries and `hidden` controls discovered
|
||||
names beginning with a dot. Both default to true. Explicit roots retain their
|
||||
normal access behavior.
|
||||
|
||||
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
|
||||
Backslashes escape pattern characters rather than acting as directory separators.
|
||||
ConfigKit validates the complete pattern before filesystem traversal, walks only
|
||||
directories selected by preceding segments, and reads wildcard directories in
|
||||
bounded batches. Every examined entry consumes the separate visit budget, including
|
||||
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
|
||||
each declaration before depth-first source traversal. A required zero-match glob is
|
||||
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
|
||||
walk failures, matched directories, invalid documents, escapes, and repeated targets
|
||||
remain errors. Final candidates are bounded by the remaining source limit before
|
||||
their contents are accumulated.
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is
|
||||
invalid. Backslashes escape pattern characters rather than acting as directory
|
||||
separators. ConfigKit validates the complete pattern before filesystem traversal,
|
||||
walks only directories selected by preceding segments, and reads wildcard
|
||||
directories in bounded batches. Every examined entry consumes the separate visit
|
||||
budget, including nonmatches. Literal components are explicit path components;
|
||||
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
|
||||
final candidate basenames.
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` provide one
|
||||
application-owned load-wide policy for enabled imports in the main source,
|
||||
underlays, and transitive imports; they do not apply to selected overlays or
|
||||
discover sources by themselves. Each applicable field resolves independently:
|
||||
an explicit declaration value wins, then the application default, then the
|
||||
library default (`required: false`, `follow: true`, `hidden: true`,
|
||||
`recursive: false`, and unrestricted filenames). A declaration's `pattern`
|
||||
replaces rather than extends the default pattern. Nil `Pattern` leaves directory
|
||||
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
|
||||
a field applicable to selectors that do not support it, and load calls snapshot
|
||||
the supplied pointers and slice before source acquisition.
|
||||
|
||||
A source's `configkit.sequences` declarations apply independently of import
|
||||
enablement; a `configkit.imports` declaration still requires `Options.Imports`.
|
||||
Sequence identities can be supplied either by these YAML declarations or by
|
||||
application registrations. The [strict composition contract](#strict-document-composition)
|
||||
defines their agreement, operation, and lifetime rules.
|
||||
|
||||
Acquisition visits declarations in authored order and each declaration's selected
|
||||
paths in namespace-path lexical order, preserving source-record and version-callback
|
||||
order. Ordinary mapping contributions in a main tier are assembled dependency-first:
|
||||
ConfigKit selects the lexically smallest stable source key among the contributions
|
||||
whose imports are complete. Independent branches may interleave, and the main body
|
||||
may be assembled after its imports. Sequence-item contributions in that tier are
|
||||
nodes in the same schedule; each selected item first assembles its own strict
|
||||
mapping scope, with helpers before the bodies that import them. Those local scopes
|
||||
do not use application collection policies and are not flattened into the outer
|
||||
schedule. Item positions and diagnostic paths follow the resulting assembly order.
|
||||
Each underlay group likewise acquires its inputs before dependency-ready assembly
|
||||
in one strict scope. Independent groups remain separate precedence tiers; at a
|
||||
declared or registered destination, the assembled higher sequence uses the
|
||||
resolved collection operation against the lower sequence. Other sequences retain
|
||||
whole-value replacement between tiers. Assembly order never permits replacement.
|
||||
|
||||
Acquisition order and resolution order serve different purposes: acquisition
|
||||
assigns source records and callback order, while the shared scheduler determines
|
||||
composition order among currently dependency-ready contributions. Inheritance
|
||||
uses the same eligibility rule within each destination-local sibling scope; it
|
||||
does not reorder the authored output mapping. The private namespace and stable-key
|
||||
construction is described in the [import implementation overview](internal/imports.md).
|
||||
|
||||
An empty selection is allowed by default. `required: true` makes an empty directory
|
||||
or glob selection fail with `import.missing`; it also makes missing named paths
|
||||
fail. Requiredness does not suppress invalid paths, containment failures,
|
||||
directories selected as files, permissions, parse errors, or composition errors.
|
||||
|
||||
For inspection, `SourceInfo.ImportSelections` records one outcome for each
|
||||
completed optional declaration whose entire selected set is empty. The record is
|
||||
attached to its declaring source, keeps the declaration path local to that source,
|
||||
identifies the declaration mapping (including its `SourceID`) and selector, and
|
||||
uses outcome `none`. Records remain in declaration order and are available
|
||||
through the existing result, load
|
||||
error, and callback source snapshots. They do not enumerate candidates, explain
|
||||
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
|
||||
regardless of requiredness: it produces a `none` record when effectively optional,
|
||||
and no record when effectively required. Snapshots detach the record paths and
|
||||
slices.
|
||||
|
||||
Each declaration may include `placement: mapping` or
|
||||
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
||||
including every match of a glob; placement is not inherited. Sequence-item
|
||||
placement requires a nonempty effective destination and wraps the imported
|
||||
mapping as one element of a sequence there. It does not split source sequences
|
||||
or infer identity fields. Repeated sequences combine only when the destination
|
||||
has an applicable YAML identity declaration or application registration; source
|
||||
placement remains independent of collection identity and operation. 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 retains its existing owner and timing. Application
|
||||
registrations also enforce identity on the final effective typed configuration;
|
||||
YAML-only declarations are enforced at structural combination and inheritance
|
||||
boundaries. The [configuration contract](#strict-document-composition) defines
|
||||
these validation boundaries.
|
||||
|
||||
An item import first opens a local mapping scope. Its body and ordinary helper
|
||||
imports compose within that record. An omitted helper destination or
|
||||
`destination: []` places the helper at the record root; other destinations are
|
||||
relative to the immediate importing source. A helper may supply fields such as
|
||||
the record's `id`. An explicit nested `sequence-item` import creates a sequence
|
||||
inside the record, and its placement is independent of the enclosing item.
|
||||
At the record root, an item import must name a nonempty nested destination.
|
||||
Physical paths continue to resolve from the file that declares each import.
|
||||
An actual empty mapping contributes an empty item, while an unmatched declaration
|
||||
with default or explicit `required: false` contributes nothing.
|
||||
|
||||
For host files, the configuration root defaults to the selected file's canonical
|
||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||
@@ -901,8 +1220,13 @@ ConfigKit canonicalizes symlinks,
|
||||
checks containment with relative paths, and opens the canonical root-relative target
|
||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path operations
|
||||
and their own symlink behavior; imports stay in their selected namespace and root.
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path
|
||||
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
|
||||
that capability to inspect and resolve discovered links; otherwise it follows
|
||||
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
|
||||
native filesystem to expose link inspection, and native filesystem behavior is
|
||||
not a general sandbox guarantee. Imports stay in their selected namespace and
|
||||
root.
|
||||
|
||||
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
||||
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||
|
||||
@@ -21,6 +21,7 @@ sanitized shapes using only ConfigKit and the Go standard library:
|
||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
||||
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
|
||||
|
||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||
executable examples and ordinary tests. It contains no private application data,
|
||||
@@ -54,6 +55,24 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||
execution profile concepts.
|
||||
|
||||
## Import defaults and selection inspection
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||
is resolved independently: a declaration's explicit value wins, then the
|
||||
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
||||
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
||||
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||
slice selects no files. A declaration's own `pattern` replaces the application
|
||||
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||
|
||||
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||
declaring source's `ImportSelections`. These records identify the source-local
|
||||
declaration path, its authored location and selector, and the `none` outcome;
|
||||
they do not include candidate names or a guessed reason for absence. Returned
|
||||
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||
owns selector applicability and exact absence semantics.
|
||||
|
||||
## Shared source-version checks
|
||||
|
||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||
@@ -163,6 +182,38 @@ relative resources, and strict peer conflicts. The
|
||||
[configuration contract](../config.md#input-destinations) owns shared placement,
|
||||
import, source-location, and ownership behavior.
|
||||
|
||||
## Identified prompt sequences
|
||||
|
||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) shows the
|
||||
smaller import-focused adoption path: a registered `prompts.id` identity,
|
||||
directory and glob selection, a helper import that supplies an item's ID, and
|
||||
resource lookup through the effective source context. Its executable examples
|
||||
and tests are in
|
||||
[`prompt_catalog_test.go`](../../examples/consumers/prompt_catalog_test.go), with
|
||||
complete [catalog](../../examples/consumers/testdata/prompt-catalog/config.yml)
|
||||
and [required-file](../../examples/consumers/testdata/prompt-catalog/simple.yml)
|
||||
fixtures.
|
||||
|
||||
[`LoadSequenceCatalog`](../../examples/consumers/sequence_catalog.go) integrates
|
||||
an underlay declaration, imported main fragments, identified-record inheritance,
|
||||
selected overlay `append`, `upsert`, and `replace`, a typed CLI override, and
|
||||
recorded application resolution. Its tests and runnable example demonstrate
|
||||
stable output order, whole-record upsert, provenance/history inspection,
|
||||
resource lookup for inherited records, and secret-safe explanation in
|
||||
[`sequence_catalog_test.go`](../../examples/consumers/sequence_catalog_test.go).
|
||||
[`LoadNamespacedMapCatalog`](../../examples/consumers/sequence_catalog.go) adds
|
||||
an exact root registration and a wildcard namespace registration, showing that
|
||||
each concrete sequence has an independent identity scope. The same tests show
|
||||
that a YAML identity declaration without an application registration does not
|
||||
create final-result identity enforcement.
|
||||
|
||||
The examples keep prompt meaning and domain checks in the consuming application.
|
||||
The [configuration contract](../config.md#strict-document-composition) owns
|
||||
declaration, identity, operation, ownership, and final-validation semantics;
|
||||
[sibling inheritance](../config.md#sibling-inheritance) defines record
|
||||
relationships, and the [CLI contract](../config.md#explicit-cli-overrides) owns
|
||||
override ordering.
|
||||
|
||||
Known module catalogs should use canonical object entries with a module identifier
|
||||
and typed options. A small union struct is sufficient when the available option
|
||||
fields are known; validation selects which fields apply to each module. Runtime-
|
||||
|
||||
@@ -43,7 +43,7 @@ public behavior.
|
||||
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
||||
| Documentation changes | The owning document |
|
||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
||||
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
|
||||
| Code and test changes | The owning contract and tests from the map below |
|
||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||
| Release preparation | [Release procedure](release.md) |
|
||||
@@ -59,6 +59,10 @@ Accepted decisions:
|
||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
|
||||
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
@@ -108,7 +112,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||
```
|
||||
|
||||
Remaining optimization candidates belong to
|
||||
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
|
||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
||||
|
||||
The completed effective-configuration diff roadmap, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `1c2610c` under
|
||||
@@ -131,7 +135,54 @@ Current behavior belongs to the configuration contract's
|
||||
[input destinations](config.md#input-destinations),
|
||||
[imports](config.md#explicit-imports), and
|
||||
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||
Possible follow-ups remain in [deferred scope](roadmap/deferred.md).
|
||||
Explicit sequence-item placement, collection operations, identity registration,
|
||||
and final registered-identity validation are owned by the
|
||||
[strict composition](config.md#strict-document-composition) section;
|
||||
[imports](config.md#explicit-imports) owns acquisition and scheduling. The
|
||||
[maintained sequence catalog](consumers/README.md#identified-prompt-sequences)
|
||||
shows the consumer boundary, and [ADR 0017](adr/0017-collection-sequence-policy.md)
|
||||
records the collection decision.
|
||||
|
||||
The completed sequence-composition roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 008d2ba:docs/roadmap/sequence-composition.md
|
||||
git show 008d2ba:docs/roadmap/implementation.md
|
||||
git show 008d2ba:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The completed import-discovery roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `5747ec4` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 5747ec4:docs/roadmap/imports.md
|
||||
git show 5747ec4:docs/roadmap/implementation.md
|
||||
git show 5747ec4:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The subsequent import-integration audit and completed remediation record are
|
||||
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||
|
||||
The completed sequence-identity/collection-policy roadmap and implementation
|
||||
plan are preserved in Git history at `d0a605a`:
|
||||
|
||||
```sh
|
||||
git show d0a605a:docs/roadmap/sequences.md
|
||||
git show d0a605a:docs/roadmap/implementation.md
|
||||
```
|
||||
|
||||
Current behavior belongs to the configuration contract's
|
||||
[strict composition](config.md#strict-document-composition),
|
||||
[sibling inheritance](config.md#sibling-inheritance), and
|
||||
[CLI overrides](config.md#explicit-cli-overrides) sections.
|
||||
The completed remediation record, including the algorithm-audit findings, is
|
||||
preserved at `ea46001:docs/roadmap/remediation.md`.
|
||||
|
||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
Acquire declared dependencies explicitly before entering an offline
|
||||
@@ -180,8 +231,10 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| --- | --- | --- |
|
||||
| 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), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
||||
@@ -194,6 +247,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -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'` |
|
||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||
|
||||
87
docs/internal/imports.md
Normal file
87
docs/internal/imports.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Import Discovery and Scheduling Internals
|
||||
|
||||
The [configuration contract](../config.md#explicit-imports) owns selector syntax,
|
||||
defaults, requiredness, placement, inspection evidence, and observable ordering.
|
||||
This note records implementation flow and private identities for maintainers.
|
||||
|
||||
## Discovery and acquisition
|
||||
|
||||
Each source's root directive is parsed into presence-aware declarations and
|
||||
resolved against load-wide defaults. Candidate selection preserves a normalized
|
||||
namespace path separately from canonical access identity: overlapping roots
|
||||
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||
through distinct spellings retain separate inclusion routes for admission.
|
||||
Directory searches walk each distinct normalized root once, apply all filename
|
||||
patterns to discovered names, and form a set union of matching candidates across
|
||||
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||
candidate normalization. They do not suppress discovery or resource errors.
|
||||
|
||||
Directory traversal tracks canonical directories on the active traversal path.
|
||||
It adds an identity when descending and removes it when the frame is popped, so
|
||||
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||
not mistaken for one. This is separate from import dependency-cycle detection.
|
||||
For host sources, discovery uses rooted `Lstat` and canonical containment. Native
|
||||
sources use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows
|
||||
the capabilities and behavior of the provided `fs.FS`, without claiming a
|
||||
general sandbox guarantee.
|
||||
|
||||
Acquisition remains authored: declarations are visited in source order and each
|
||||
selected candidate set in namespace-path lexical order. This preserves source
|
||||
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||
inclusion graph is retained separately from composition scopes so cycles that
|
||||
cross item boundaries remain detectable. Source discovery does not itself choose
|
||||
sequence assembly order.
|
||||
|
||||
## Stable keys and scheduling
|
||||
|
||||
The shared scheduler compares immutable tuples component by component using Go's
|
||||
bytewise string order; if one tuple is a prefix of another, the shorter tuple
|
||||
sorts first. The import key is
|
||||
`(namespace-kind, namespace-anchor, resource-kind, resource-path)`:
|
||||
|
||||
- Namespace kind is `host` or `native`. Host resource paths are canonical
|
||||
absolute paths rendered with slash separators; native paths retain their
|
||||
namespace-relative identity.
|
||||
- Anchors come from explicit input positions, not discovery order: main is
|
||||
`(0,0)` and underlay/input positions are `(group+1,input+1)`, each encoded as
|
||||
fixed-width decimal. Host accesses in one scheduled tier share its host anchor.
|
||||
A native filesystem namespace uses its earliest explicit selecting position
|
||||
in that tier. Filesystem tokens distinguish namespace equality, not sort order,
|
||||
so separately constructed filesystems with the same label remain distinct.
|
||||
- Resource kind is `file` or `synthetic`. A synthetic root uses its explicit
|
||||
input anchor and an empty resource path rather than a display label or token
|
||||
address.
|
||||
|
||||
Inheritance keys are
|
||||
`("definition", scope-pointer, definition-pointer)`, where both pointers use
|
||||
canonical escaped semantic path spelling. Source IDs are not part of the key.
|
||||
|
||||
Both integrations use the same Kahn scheduler: it deduplicates dependency edges,
|
||||
keeps currently ready nodes in a min-heap, and selects the smallest ready key
|
||||
after every completion. Its scheduling cost is `O(E + V log V)` time and
|
||||
`O(V + E)` additional memory, excluding key construction, tuple copying, and
|
||||
tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
|
||||
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||
reported as cycle members.
|
||||
|
||||
Import composition schedules a main tier or one underlay group as a single
|
||||
strict-scope graph. Applicable collection declarations are gathered for the
|
||||
scope before peer composition, including identity inherited from lower tiers.
|
||||
Identified peer sequences then combine distinct records in assembled source and
|
||||
list order; after the strict scope completes, layered assembly applies the
|
||||
resolved collection operation against the lower value, as defined by the
|
||||
[configuration contract](../config.md#strict-document-composition). Item
|
||||
contributions own nested local strict-mapping scopes; their helpers complete
|
||||
before the item is placed in its outer scope. Inheritance uses the same
|
||||
ready-node rule inside each sibling mapping scope, while preserving the authored
|
||||
mapping order in its output.
|
||||
These scopes do not flatten into one global graph, and independent precedence
|
||||
tiers remain separate.
|
||||
|
||||
The main owners are [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||
[`import_graph.go`](../../internal/engine/import_graph.go),
|
||||
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
|
||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
||||
Their focused behavior is covered by import-selection, scheduler, and scheduling
|
||||
integration tests under `internal/engine`.
|
||||
@@ -9,9 +9,17 @@ Implemented component owners:
|
||||
|
||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
||||
placement and immediate-parent-relative import placement within that
|
||||
assembly, destination-local sibling inheritance before typed decoding, and
|
||||
and sequence-item placement, YAML collection declarations and operations for
|
||||
identified sequences, and application identity registrations for strict
|
||||
composition, inheritance, and final validation. It applies the selected
|
||||
collection operation at precedence boundaries and owns dependency-ready,
|
||||
destination-local inheritance before typed decoding. CLI assignment follows
|
||||
inheritance and decoding, before application resolution. The engine also owns
|
||||
shared source-version checking with sanitized declaration evidence.
|
||||
Explicit import discovery supports declared paths, directory searches, and
|
||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
||||
root facade without changing acquisition order. Import and inheritance
|
||||
resolution share dependency-ready scheduling within their composition scopes.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
@@ -48,6 +56,11 @@ The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||
semantics, including [input destinations](../config.md#input-destinations),
|
||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||
The [strict composition contract](../config.md#strict-document-composition)
|
||||
owns sequence declarations, identity, operations, ownership, and final
|
||||
validation; [CLI overrides](../config.md#explicit-cli-overrides) own override
|
||||
ordering. The [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
links maintained workflow examples.
|
||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||
The [architecture policy](../policy/architecture.md) owns contributor
|
||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||
@@ -59,3 +72,11 @@ and [release procedure](../release.md) for source-candidate gates. [ADR
|
||||
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
|
||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||
records the source-version decision.
|
||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||
sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
records the shared dependency-ready ordering decision; [ADR
|
||||
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
||||
operations and CLI ordering. The [import internals](imports.md) document candidate and
|
||||
namespace identities, discovery and scheduling, and the bounded algorithms.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-09-29
|
||||
**Last Updated**: 2026-10-22
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||
|
||||
@@ -19,8 +19,8 @@ to ADRs for decision rationale.
|
||||
ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
||||
and resolution across consuming applications. The [component overview](../internal/overview.md)
|
||||
owns implementation inventory, the [configuration contract](../config.md) owns
|
||||
implemented behavior, and the [deferred scope](../roadmap/deferred.md) preserves
|
||||
deferred scope.
|
||||
implemented behavior, and the [future feature list](../roadmap/future.md) preserves
|
||||
candidates for future consideration.
|
||||
|
||||
## Adopted Constraints And Implemented Structure
|
||||
|
||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
||||
Constraints must reflect deliberately selected requirements and decisions.
|
||||
Do not promote incidental implementation choices into architectural invariants.
|
||||
|
||||
The implemented collection-policy and CLI-ordering constraints below reflect
|
||||
accepted [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The configuration
|
||||
contract owns their current behavior; these architectural constraints describe
|
||||
the system boundaries and guarantees that guide changes to that behavior.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||
@@ -91,6 +97,48 @@ boundaries, and keep simple consumers independent of optional composition
|
||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||
implemented surface against representative simple and complex shapes.
|
||||
|
||||
## Simplicity, Defaults, And Explicit Controls
|
||||
|
||||
These principles apply throughout ConfigKit: to configuration syntax, the public
|
||||
Go API, defaults, and feature design. Evaluate their effects on both configuration
|
||||
authors and consuming applications.
|
||||
|
||||
**Keep straightforward workflows straightforward.** Common configuration
|
||||
workflows should require little configuration text and minimal API setup.
|
||||
Support more demanding workflows through explicit, composable options, without
|
||||
requiring simple consumers to understand or configure those options.
|
||||
|
||||
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
|
||||
defaults that minimize required setup and accommodate valid configurations
|
||||
without unnecessary restrictions. Provide selectable options for workflows that
|
||||
need additional capabilities, narrower behavior, or stronger requirements.
|
||||
|
||||
Measure simplicity by what consumers must understand and maintain, including
|
||||
configuration text and application-side plumbing. Concise syntax is useful, but
|
||||
hidden inference and interacting meanings can make a shorter declaration harder
|
||||
to understand. Prefer explicitness where it makes behavior more predictable.
|
||||
A terse configuration format that requires substantial application-side
|
||||
infrastructure does not satisfy the simplicity principle.
|
||||
|
||||
Define what permissiveness means for each behavior. Allowing an optional
|
||||
contribution to be absent or accepting valid inputs without an unnecessary
|
||||
filename restriction can reduce required setup. Invalid declarations, ambiguous
|
||||
composition, and unreadable selected sources must still produce meaningful
|
||||
errors under the applicable contracts. Permissiveness does not authorize silent
|
||||
recovery from invalid supplied configuration or weaken configuration integrity.
|
||||
|
||||
Options should express coherent choices with understandable purposes and fit
|
||||
ConfigKit's shared configuration model. Support powerful controls when they
|
||||
serve concrete needs; these principles do not require a switch for every
|
||||
implementation behavior or conceivable consumer preference.
|
||||
|
||||
Defaults require judgment when simplicity and permissiveness pull in different
|
||||
directions. Broader discovery can accommodate more layouts while increasing the
|
||||
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
|
||||
not mechanically select the broadest behavior for every option. Changes to
|
||||
existing defaults remain subject to compatibility review, and proposed behavior
|
||||
remains in the roadmap until implemented.
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||
@@ -111,6 +159,14 @@ the import extension.
|
||||
Library code must not depend on consuming applications or initialize their
|
||||
runtime resources. It must not perform implicit network access, discover
|
||||
application-specific paths, or change process-global state during ordinary `Load`.
|
||||
Import discovery is available only through declarations in selected sources:
|
||||
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||
ConfigKit does not search conventional application paths or discover a main
|
||||
configuration file implicitly. Imports and sibling inheritance share
|
||||
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
|
||||
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
||||
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||
must never run implicitly as part of configuration loading.
|
||||
@@ -133,6 +189,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
||||
the precise completion and ownership behavior. The proposed decision record is
|
||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||
|
||||
### Accepted Processing Order
|
||||
|
||||
Complete source composition and precedence, including selected overlays, before
|
||||
sibling inheritance. Then apply typed defaults and decode the configuration.
|
||||
Apply explicit CLI overrides to that configuration and check their types before
|
||||
running application resolution. Effective secret lookup and final validation
|
||||
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
|
||||
records this ordering.
|
||||
|
||||
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||
must not introduce structural directives; earlier source, inheritance, and
|
||||
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||
Semantic validation assesses the final configuration after overrides and resolution.
|
||||
|
||||
Applications own flag parsing and domain resolution. ConfigKit owns override
|
||||
application, validation, and source attribution. Resolution receives CLI values
|
||||
with their explicit-presence and source information. Absence-based defaults
|
||||
preserve explicit values; recorded normalization and derivation can still change
|
||||
them. Typed defaults and application resolution retain their own assignment
|
||||
semantics, independently of collection operations during source assembly.
|
||||
|
||||
## Public API And Dependency Direction
|
||||
|
||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||
@@ -161,31 +240,44 @@ framework on this library.
|
||||
|
||||
## Configuration Integrity
|
||||
|
||||
Within a configuration tier, independent contributions use strict composition:
|
||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
||||
are conflicts, including equal values. Between precedence tiers, intentional
|
||||
overlay combines mappings recursively, retains omitted lower-priority children,
|
||||
and replaces other present values according to the existing overlay rules.
|
||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
||||
reporting, not permission to override. The distinction and its rationale are
|
||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||
Within a configuration tier, independent contributions use strict composition.
|
||||
Shared mappings combine recursively. Sequences with an applicable identity
|
||||
combine distinct records and reject duplicate identities, including equal
|
||||
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||
values. Conflicting identity or operation declarations among peers also fail.
|
||||
Traversal order establishes stable reporting and record order; it does not grant
|
||||
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||
records the distinction between strict composition and intentional overrides.
|
||||
|
||||
The main document and its imports form a strictly composed tier. Explicit
|
||||
underlay groups compose strictly within each group, then combine recursively
|
||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||
determine its precedence role. Input-specific constraints, including CLI path
|
||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
||||
semantics do not require identical acquisition or lifetime behavior from every
|
||||
source kind.
|
||||
underlay groups compose strictly within each group, then combine from lowest to
|
||||
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||
other present values retain the established replacement rules. Source acquisition
|
||||
kind does not determine precedence. Input-specific constraints, including version
|
||||
ownership and path protections, remain part of the configuration contract.
|
||||
Consistent combination semantics do not require identical acquisition or lifetime
|
||||
behavior from every source kind.
|
||||
|
||||
After source precedence is complete, built-in sibling `extends` relationships
|
||||
resolve structurally in the same parser-independent representation, using the
|
||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
||||
consumer registration mechanism. Enclosing relationships precede nested
|
||||
destination-local resolution; typed defaults, application resolution, and
|
||||
secret lookup retain their existing lifecycle roles. The [configuration
|
||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
||||
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||
parser-independent document representation. Preserve existing mapping-sibling
|
||||
scopes and allow an identified sequence to provide a sibling scope for its
|
||||
records. A record may inherit from one parent in that sequence, selected by
|
||||
complete identity. The child supplies and keeps its own identity while inheriting
|
||||
the parent's other settings. Parents remain ordinary records subject to normal
|
||||
validation.
|
||||
|
||||
Use the shared dependency ordering and preserve assembled sequence order.
|
||||
Resolve enclosing relationships before nested destination-local relationships.
|
||||
Mappings combine recursively; sequences use the inheriting collection's policy.
|
||||
Whole-record upsert replaces records during combination; explicit inheritance
|
||||
uses the records that remain. Inheritance remains built-in and creates no
|
||||
acquired source.
|
||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
|
||||
original scope and continuing ownership decisions, with sequence combination
|
||||
and identified-sequence inheritance defined by
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||
|
||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||
@@ -194,13 +286,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
||||
|
||||
User-controlled precedence is application defaults, ordered underlays, composed
|
||||
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
|
||||
Domain resolution follows these
|
||||
inputs and must remain recorded, not become a hidden override channel.
|
||||
This priority order is distinct from processing order: typed defaults are applied
|
||||
at decoding, and application resolution follows CLI input under the lifecycle
|
||||
rules above.
|
||||
|
||||
Preserve node-based composition until the effective input is ready for typed
|
||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
||||
values must not be used to infer whether input was absent. Final configuration,
|
||||
Preserve node-based composition through source assembly and inheritance until
|
||||
typed decoding. Preserve presence and source information through later CLI and
|
||||
application-resolution changes as well. Ordinary Go zero values must not be used
|
||||
to infer whether input was absent. Final configuration,
|
||||
metadata, and effective inspection must agree, including application resolution
|
||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
||||
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||
@@ -217,6 +310,72 @@ comparison and disclosure behavior, the [component overview](../internal/overvie
|
||||
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||
records the accepted decision.
|
||||
|
||||
## Collection Identity And Combination
|
||||
|
||||
ConfigKit owns a common collection policy for imported records, inline sequences,
|
||||
selected overlays, and sibling inheritance. Configuration authors may declare
|
||||
identity and combination rules; applications may register authoritative identity
|
||||
constraints. Configuration declarations must agree with application constraints.
|
||||
Keep source placement explicit and separate from policy, retaining the placement
|
||||
decision in [ADR 0014](../adr/0014-sequence-placement-and-composition.md).
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) owns the rationale for
|
||||
collection policy and its relationship to application authority.
|
||||
|
||||
Gather declarations for the relevant destination and source or item-local scope
|
||||
before strict composition. Applicable identity from accumulated lower tiers must
|
||||
be available when composing higher-tier peers. Higher-tier declarations cannot
|
||||
repair invalid lower-tier composition. Preserve the distinction between source
|
||||
acquisition, dependency-ready scheduling within scopes, and precedence order.
|
||||
|
||||
Strictly compose each tier's contributions before applying one operation for the
|
||||
collection against its lower value. Support whole-sequence replacement,
|
||||
duplicate-rejecting append, and whole-record replacement by identity (`upsert`).
|
||||
Append and upsert require identity; do not infer it from record fields or position.
|
||||
Duplicate identities among peers fail under every mode. Source precedence and
|
||||
inheritance use the same operations and deterministic ordering rules. Whole-record
|
||||
upsert replaces the matching record's fields together; recursive record merging
|
||||
and unkeyed sequence concatenation are outside this operation model.
|
||||
|
||||
An explicit mode persists at its applicable destination until a higher explicit
|
||||
mode changes it. With no explicit mode, effective identity selects upsert and
|
||||
its absence selects whole-sequence replacement. Inferred defaults do not become
|
||||
persistent instructions. The inheriting destination chooses its operation; copying
|
||||
a parent collection does not automatically copy its mode to that destination.
|
||||
|
||||
Track YAML identity and explicit modes according to their separate lifetimes.
|
||||
Actual whole-value replacement discards lower YAML identity while preserving
|
||||
application constraints and any new identity declaration. Item-local policies
|
||||
belong to their records: retaining a record retains its rules, and discarding it
|
||||
discards those rules. Policies independently applicable at a destination remain
|
||||
in effect. Inheritance can copy YAML identity subject to agreement and replacement
|
||||
rules; application registrations continue to constrain their registered paths.
|
||||
Numeric positions alone do not establish policy ownership.
|
||||
|
||||
Allow declarations without accompanying records. A key declaration validates a
|
||||
retained sequence immediately or remains pending in its owning scope when no
|
||||
sequence exists. A mode-only declaration affects later combinations without
|
||||
recombining earlier tiers. Neither declaration alone clears a collection, and
|
||||
declaring replace alone does not authorize changing a retained collection's
|
||||
identity. These rules do not independently impose path-existence or shape
|
||||
requirements.
|
||||
|
||||
Enforce YAML identity during source composition, precedence, and identified
|
||||
inheritance operations. Application registrations additionally constrain final
|
||||
effective sequences after defaults, CLI overrides, and application resolution.
|
||||
The application schema and validation own existence, nullability, and allowed
|
||||
shape. The final identity guarantee covers actual sequences on successful load;
|
||||
it does not cover every intermediate callback state or later caller mutations.
|
||||
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||
assignments, or application resolution.
|
||||
|
||||
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||
available origin information without exposing identity values. The guarantee
|
||||
requires no new mutation-history system or exact reconstruction of causes for
|
||||
records supplied by defaults or callbacks. Exact identity equality, declaration
|
||||
syntax, presence cases, and operation tables belong in the
|
||||
[configuration contract](../config.md#strict-document-composition).
|
||||
|
||||
## Ownership And Confidentiality
|
||||
|
||||
Returned configuration and metadata must not share mutable state across loads.
|
||||
@@ -243,7 +402,7 @@ for safe messages supplied by their hooks.
|
||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||
merge strategies, additional configuration languages, or application runtime
|
||||
management without concrete consumer evidence and an explicit design decision.
|
||||
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
||||
owns the deferred-feature inventory. Significant boundary
|
||||
changes require an ADR and corresponding policy/contract updates; implementation
|
||||
convenience alone is not a reason to broaden the public API.
|
||||
Record significant decisions to exclude capabilities, and their rationale, in
|
||||
ADRs. Significant boundary changes require an ADR and corresponding
|
||||
policy/contract updates; implementation convenience alone is not a reason to
|
||||
broaden the public API.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.11
|
||||
|
||||
**Last Updated**: 2026-09-28
|
||||
**Last Updated**: 2026-10-16
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||
|
||||
@@ -38,9 +38,10 @@ roadmap owns implementation status and sequencing until the decision is
|
||||
implemented. Current architecture, user, operator, integration, and internal
|
||||
documentation are updated when the behavior lands.
|
||||
|
||||
`docs/roadmap/deferred.md` owns deliberate exclusions; task-specific plans
|
||||
own execution order and progress. Completed plans retire during release
|
||||
preparation.
|
||||
`docs/roadmap/future.md` owns potential enhancements and their open questions;
|
||||
task-specific plans own execution order and progress. Record significant
|
||||
architectural exclusions and their rationale in ADRs. Do not maintain a roadmap
|
||||
inventory of rejected proposals. Completed plans retire during release preparation.
|
||||
|
||||
### Audience And Detail
|
||||
|
||||
@@ -91,7 +92,7 @@ secret values.
|
||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and conditions for reconsideration. | Task execution and current behavior. |
|
||||
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
|
||||
@@ -156,8 +157,10 @@ Use sequentially numbered ADR filenames such as
|
||||
|
||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
||||
belong in the roadmap. Routine choices need no ADR.
|
||||
Rejected architectural alternatives and significant architectural exclusions
|
||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
||||
represent a significant architectural decision.
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||
validation toolchain. Retire completed planning and measurement records into
|
||||
Git history while preserving [deferred work](../roadmap/deferred.md).
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
|
||||
70
docs/releases/v0.8.0.md
Normal file
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)
|
||||
111
docs/releases/v0.9.0.md
Normal file
111
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# ConfigKit v0.9.0
|
||||
|
||||
This minor release expands import selection, adds author-declared collection
|
||||
identity and operations, and supports inheritance between identified records.
|
||||
It also moves CLI assignments after inheritance and typed decoding. These changes
|
||||
include breaking API and configuration behavior changes from v0.8.0; review the
|
||||
migration guidance below before upgrading.
|
||||
|
||||
## Changes
|
||||
|
||||
- Extend imports with named-file and directory lists, directory filename patterns,
|
||||
exclusions, recursion and depth controls, and explicit hidden-file and symlink
|
||||
discovery controls. Add `ImportDefaults` to ordinary and layered options, with
|
||||
authored declarations taking precedence over application defaults. Imports are
|
||||
optional by default. `SourceInfo.ImportSelections` exposes completed optional
|
||||
declarations that selected no sources. See the
|
||||
[import contract](../config.md#explicit-imports) and
|
||||
[consumer guidance](../consumers/README.md#import-defaults-and-selection-inspection).
|
||||
- Use shared dependency-ready ordering for import composition and sibling
|
||||
inheritance. Dependencies complete before dependents, with deterministic
|
||||
selection among currently eligible contributions. Source acquisition and
|
||||
callback order remain separate from assembly order. See the
|
||||
[ordering decision](../adr/0015-shared-dependency-ready-ordering.md).
|
||||
- Add root `configkit.sequences` declarations for collection identity and
|
||||
`replace`, `append`, or `upsert` operations, including in selected overlays.
|
||||
Strict peers combine distinct identities and reject duplicates; precedence
|
||||
and inheritance apply the destination's collection operation. Upsert replaces
|
||||
a whole matching record at its retained position. Identity supports compound
|
||||
field sets and scalar strings, numbers, booleans, and null. See the
|
||||
[collection contract](../config.md#strict-document-composition).
|
||||
- Add `Definition.SequenceIdentities`, exact `SequenceIdentityAt` registrations,
|
||||
and dynamic `SequenceIdentityMatching` selectors. Application registrations
|
||||
govern structural collection operations and check existing matching final
|
||||
typed sequences, including values from defaults, CLI assignments, and
|
||||
application resolution. YAML-only declarations govern structural operations
|
||||
without adding that final-result check.
|
||||
- Support record inheritance within an identified sequence using an `extends`
|
||||
mapping containing the parent's complete identity. The child retains its own
|
||||
identity, relationships use surviving assembled records, and output sequence
|
||||
order is preserved. See [sibling inheritance](../config.md#sibling-inheritance)
|
||||
and the maintained [sequence catalog](../consumers/README.md#identified-prompt-sequences).
|
||||
- Apply explicit CLI overrides to the decoded configuration before application
|
||||
resolution. Mappings retain omitted fields and their presence metadata;
|
||||
sequences replace whole lists independently of collection operations. See the
|
||||
[CLI contract](../config.md#explicit-cli-overrides).
|
||||
- Correct native import alias and contained-symlink handling, source-version
|
||||
path projection, and provenance/history through collection movement,
|
||||
replacement, inheritance, and CLI assignment. Reduce repeated scans,
|
||||
accumulated policy copying, and sparse-projection storage during construction.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
This pre-v1 minor release intentionally changes the v0.8.0 contracts:
|
||||
|
||||
1. **Migrate import requiredness.** The YAML `optional` field is removed and is
|
||||
rejected. Replace `optional: true` with `required: false`; omission is equivalent
|
||||
when the application does not default imports to required. Replace
|
||||
`optional: false` with `required: true`. Imports that previously relied on
|
||||
implicit requiredness now need `required: true`, or an application
|
||||
`ImportDefaults.Required` default set to true. Review discovery filters:
|
||||
`follow` and `hidden` default to true, and directory searches select all
|
||||
filenames unless a pattern is supplied.
|
||||
2. **Replace sequence registrations.** `SequenceComposition` and
|
||||
`Definition.SequenceCompositions` are removed without compatibility aliases.
|
||||
For a previous registration at `prompts` using `id`, use:
|
||||
|
||||
```go
|
||||
rules.SequenceIdentities = []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
}
|
||||
```
|
||||
|
||||
These registrations now also enforce identity in final typed sequences. Fix
|
||||
missing or duplicate identities introduced by defaults, CLI assignments, or
|
||||
application resolution. Registration does not require a collection to exist
|
||||
or determine its allowed shape; schema and application validation retain
|
||||
those responsibilities.
|
||||
3. **Review collection precedence and ordering.** An identified collection now
|
||||
defaults to upsert across layers and inheritance when no explicit mode
|
||||
applies, rather than unconditional whole-sequence replacement. Declare
|
||||
`mode: replace` at the relevant destination where replacement is intended.
|
||||
With append or upsert, `[]` contributes no records and does not clear the
|
||||
collection; null is invalid. Use `mode: replace` with `[]` to clear it.
|
||||
An explicit mode persists at its destination until changed; a parent's mode
|
||||
does not transfer to a different inheriting destination. Unidentified
|
||||
sequences retain strict peer conflicts and whole-value precedence
|
||||
replacement. Dependency-ready assembly can change sequence positions and
|
||||
diagnostic or history order compared with the former traversal order.
|
||||
4. **Review CLI recovery and inheritance assumptions.** CLI overrides can no
|
||||
longer repair values that fail earlier decoding, bypass source or inheritance
|
||||
errors, or add or cancel inheritance relationships. Supply structural
|
||||
directives in source configuration. Application resolution still receives
|
||||
explicit CLI values and may record subsequent normalization or derivation.
|
||||
5. **Update affected Go literals and diagnostic handling.** Use keyed literals
|
||||
for expanded option and source-information structs. Collection validation adds
|
||||
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||
(`sequence.conflict`); update diagnostic handling where applicable.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.9.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.9.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/docs/oss/attribution.md)
|
||||
222
docs/roadmap/complexity.md
Normal file
222
docs/roadmap/complexity.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Internal Complexity Refactoring Roadmap
|
||||
|
||||
Status: proposed target state. This roadmap defines a behavior-preserving
|
||||
refactor; a later implementation plan will define stages and exact private
|
||||
signatures and file names.
|
||||
|
||||
## Purpose
|
||||
|
||||
Make ConfigKit's import, inheritance, and projection implementation easier to
|
||||
understand, review, and change while preserving its public APIs and configuration
|
||||
behavior. Concentrate on functions that coordinate several responsibilities and
|
||||
carry substantial shared state.
|
||||
|
||||
The target is clearer responsibility and ownership. Adding a few well-named
|
||||
helpers, private records, or focused files can serve that target even when total
|
||||
line count increases. Complexity measurements help locate candidates; they do
|
||||
not establish function-size limits, score thresholds, or release gates.
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns package and ownership
|
||||
constraints. The [configuration contract](../config.md) and exported Go comments
|
||||
remain authoritative for consumer behavior.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
Reviewed baseline: `7a83704f43ef9870db33661231cdff5d6ee502f7`.
|
||||
|
||||
The root facade delegates to a cohesive private engine, supported by the ordered
|
||||
document representation and YAML adapter. Most functions are small. The
|
||||
complexity audit identified these concentrated maintenance costs:
|
||||
|
||||
| Area | Current implementation | Required improvement |
|
||||
| --- | --- | --- |
|
||||
| Imports | `imports.go` spans acquisition, item-local composition, and evidence projection. Private loading variants pass long positional argument lists. Source admission and directory selection contain substantial mixed control flow. | Give import entry points explicit, cohesive inputs and separate the responsibilities of acquisition, scope assembly, and evidence projection. |
|
||||
| Inheritance | `applyInheritanceScopeTemplates` coordinates payload preparation, parent combination, policy ownership, origins, history, and accounting in one executor. | Keep one readable executor with focused operations and explicit state handoffs. |
|
||||
| Projection | `valueOperation.project` combines type dispatch with struct flattening, map and sequence traversal, scalar conversion, sensitivity, and accounting. | Retain clear dispatch while moving substantial value-kind handling into focused helpers. |
|
||||
|
||||
The implemented inventory belongs to the
|
||||
[component overview](../internal/overview.md). Existing import flow and scheduling
|
||||
are described in [import internals](../internal/imports.md).
|
||||
|
||||
## Target Architecture
|
||||
|
||||
The refactor stays within `internal/engine`. The root facade, `internal/document`,
|
||||
and `internal/yaml` retain their existing responsibilities and dependency
|
||||
direction. Focused files and ordinary private functions express the new internal
|
||||
organization. Package extraction is outside this roadmap's selected design.
|
||||
|
||||
`executeLoadRequest` remains the visible coordinator of the shared load lifecycle.
|
||||
Its phase order should remain easy to follow. Helpers expose meaningful work and
|
||||
results rather than distributing that order across a generic execution framework.
|
||||
|
||||
Existing shared mechanisms remain shared: recursive mapping combination,
|
||||
identified-sequence operations, dependency-ready scheduling, schema descriptors,
|
||||
and projection/accounting state. New helper boundaries make their ownership and
|
||||
use clearer without creating parallel implementations.
|
||||
|
||||
### Imports: Cohesive Inputs And Scope Ownership
|
||||
|
||||
Import loading has a clear private entry surface using named request/context
|
||||
fields for related inputs. Reuse the existing load-request, acquisition-scope,
|
||||
and traversal models where they fit. Consolidate forwarding variants whose only
|
||||
purpose is passing another optional positional argument through the chain.
|
||||
Retain convenience helpers when they express a useful, distinct operation.
|
||||
|
||||
Keep these state lifetimes identifiable:
|
||||
|
||||
- Load-wide state: accounting, shared inclusion/repetition information,
|
||||
namespaces, and source-version processing.
|
||||
- Acquisition requests: source and filesystem context, placement, declarations,
|
||||
applicable policy, and the relevant assembly handoff.
|
||||
- Composition scopes: peer contributions, item-local helpers, record ownership,
|
||||
and pending declarations.
|
||||
- Evidence: physical source records, effective destinations, origins, and retained
|
||||
history projections.
|
||||
|
||||
Group acquisition, pending-item assembly, and evidence projection in focused
|
||||
files. A caller should be able to identify which operation acquires resources,
|
||||
which composes already acquired content, and which projects retained evidence.
|
||||
Shared mutable state has a visible owner; a request record need not duplicate
|
||||
state already held by that owner.
|
||||
|
||||
Within dense admission, directory-selection, and declaration-parsing routines,
|
||||
extract coherent operations where that reduces nested, mixed responsibilities.
|
||||
Preserve the existing selection algorithms and host/native filesystem behavior.
|
||||
Resource ownership and closure remain explicit across success and failure paths.
|
||||
|
||||
The resulting flow preserves the distinction among acquisition order, strict
|
||||
scope scheduling, and precedence. Completing a helper scope before placing its
|
||||
item remains part of the same import flow. Ordinary and layered loading continue
|
||||
to share the applicable mechanisms.
|
||||
|
||||
### Inheritance: One Executor With Explicit Phases
|
||||
|
||||
Keep the mapping and identified-sequence adapters and their shared template
|
||||
executor. The executor coordinates focused operations for:
|
||||
|
||||
- Preparing child payload, directives, and destination-local state.
|
||||
- Preparing the resolved parent payload and applicable inherited policy.
|
||||
- Combining values through the existing mapping and sequence mechanisms.
|
||||
- Producing policy, origin, replacement, and replay evidence from that combination.
|
||||
- Accounting for expansion and retained history under the existing budgets.
|
||||
|
||||
These are responsibility boundaries, not a required helper count or a new
|
||||
scheduler. Small private result records are appropriate when related outputs
|
||||
otherwise travel through long argument lists or implicit mutations.
|
||||
|
||||
Authored templates, resolved values, scoped policy, and inspection evidence remain
|
||||
distinguishable. Immutable borrowed inputs and owned mutable results have clear
|
||||
lifetimes. Nested instructions remain available for destination-local evaluation.
|
||||
For identified-record inheritance, the child's own identity fields and origins
|
||||
remain intact; the parent's identity fields are excluded from inherited payload
|
||||
and its history, as the current contract requires.
|
||||
|
||||
One sequence-operation result continues to drive value construction, record
|
||||
ownership, origins, and historical position projection. Helpers consume that
|
||||
evidence directly. Scheduling remains dependency-ready, enclosing relationships
|
||||
resolve before nested ones, and assembled sequence order remains unchanged.
|
||||
|
||||
### Projection: Explicit Value-Kind Handlers
|
||||
|
||||
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
|
||||
the owner of per-operation traversal and accounting state. The central dispatch
|
||||
clearly selects the appropriate handling for pointers, dynamic values, structs,
|
||||
maps, sequences, and scalars.
|
||||
|
||||
Substantial struct, map, and sequence work lives in focused handlers. Struct
|
||||
projection retains declaration order, inline flattening, collision diagnostics,
|
||||
and sensitivity propagation. Map projection retains deterministic key ordering.
|
||||
Scalar conversion retains the existing number, duration, text-codec, and secret
|
||||
reference representations.
|
||||
|
||||
Depth and node accounting retain their existing meaning, including flattened
|
||||
containers. Cycle tracking stays active through recursive child traversal and
|
||||
is balanced on error paths as well as success. Passing state through a helper
|
||||
does not introduce another full-tree projection or an unnecessary ownership copy.
|
||||
|
||||
Cloning and source-value construction remain distinct operations with their
|
||||
existing semantics. Reuse small helpers when the semantics coincide; keep the
|
||||
projection refactor centered on making the existing traversal readable.
|
||||
|
||||
## Behavior And Scope Preservation
|
||||
|
||||
The implementation preserves:
|
||||
|
||||
- Root-owned public types, signatures, supported import paths, and facade boundaries.
|
||||
- Supported source forms, opt-ins, import controls, placement, acquisition and
|
||||
composition ordering, and source/version evidence.
|
||||
- Strict peer composition, precedence operations, identity agreement and lifetime,
|
||||
record ownership, and destination-local inheritance.
|
||||
- The load lifecycle, including typed defaults, post-decode CLI assignment,
|
||||
recorded application resolution, and final validation.
|
||||
- Explicit presence, provenance, history, sensitivity, resource contexts, and
|
||||
detached publication/query results.
|
||||
- Established diagnostic codes, semantic paths, source coordinates, ordering,
|
||||
resource lifetime, and existing limit behavior.
|
||||
|
||||
The owning contracts define the extent of these obligations. Incidental private
|
||||
call sequences, exact helper names, and allocation counts do not acquire new
|
||||
guarantees through this roadmap. The work adds no consumer options, configuration
|
||||
syntax, dependencies, or complexity-based gates.
|
||||
|
||||
## Relationship To Remediation And Other Work
|
||||
|
||||
The completed remediation record at `ea46001:docs/roadmap/remediation.md`
|
||||
documents the identified correctness and algorithmic efficiency findings.
|
||||
Several overlap these files, particularly item-scope application matching and
|
||||
inheritance policy localization.
|
||||
|
||||
Use the documented contract and completed remediation as the behavioral baseline.
|
||||
Keep corrective changes reviewable under their findings, and retain their
|
||||
regressions through this refactor. The later implementation plan should coordinate
|
||||
overlapping work and select its starting revision rather than duplicating the
|
||||
remediation plan here. These structural changes do not close a finding merely
|
||||
because its function moved or became smaller.
|
||||
|
||||
The pending [discovery roadmap](discovery.md) remains separate feature work.
|
||||
Import cleanup should leave clear private boundaries for future use without
|
||||
implementing discovery behavior as part of this refactor.
|
||||
|
||||
## Work Needed And Validation
|
||||
|
||||
Implementation needs to consolidate import inputs and adapters, separate import
|
||||
responsibilities, extract inheritance operations with explicit outputs, and
|
||||
decompose projection handling. Remove superseded private plumbing as its callers
|
||||
migrate. Review the resulting call flow for repeated traversals, policy scans,
|
||||
copies, or hidden state transitions introduced by extraction.
|
||||
|
||||
Reuse the existing behavioral suites under the
|
||||
[development guide](../development.md) and [testing policy](../policy/testing.md):
|
||||
|
||||
- Imports: selection, scheduling, source/version evidence, placement, helper scopes,
|
||||
ordinary/layered equivalence, filesystem failures, and resource lifetime.
|
||||
- Inheritance: mapping and record relationships, nested destination resolution,
|
||||
cancellation and replacement, policy ownership, history, sensitivity, and
|
||||
independent loads.
|
||||
- Projection: schema/decoding agreement, inline fields, text codecs, nil/empty
|
||||
values, cycles and budgets, secrets, and detached results.
|
||||
|
||||
Add focused regressions only where a consequential handoff risk lacks coverage.
|
||||
Tests should continue to describe behavior as private functions and files move.
|
||||
Use the existing everyday check and applicable ownership race checks during
|
||||
implementation. Complexity measurements can inform review; acceptance does not
|
||||
depend on numerical scores or a new benchmark campaign.
|
||||
|
||||
Update the component overview and import internals to describe the implemented
|
||||
ownership and file responsibilities when the refactor lands. Update contributor
|
||||
navigation and test-owner references where moves affect them. Public behavior
|
||||
continues to be defined by its current canonical owners.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Import callers pass cohesive named inputs, with clearly owned load-wide and
|
||||
scope-local state and fewer redundant forwarding variants.
|
||||
- Import acquisition, item assembly, and evidence projection are easy to locate
|
||||
and follow across focused private files.
|
||||
- Inheritance orchestration exposes value, policy, evidence, and accounting work
|
||||
without requiring readers to reconstruct their interaction from one large body.
|
||||
- Projection dispatch and substantial value-kind handlers have clear boundaries
|
||||
and share the original operation state correctly.
|
||||
- Existing APIs and contracted behavior, including completed remediation, remain
|
||||
covered by the relevant behavioral and ownership checks.
|
||||
- Internal documentation matches the resulting implementation.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Deferred Scope
|
||||
|
||||
This document owns intentionally excluded features, not a committed delivery plan.
|
||||
The [architecture policy](../policy/architecture.md) governs changes to these
|
||||
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
|
||||
and consumer adoption in the [consumer guide](../consumers/README.md).
|
||||
|
||||
## Explicit non-goals and deferred scope
|
||||
|
||||
General environment-variable configuration overlays are not supported. Typed secret
|
||||
references and explicit directory bootstrap are the environment convention. Literal
|
||||
credential configuration and implicit secret-directory discovery are unsupported.
|
||||
|
||||
The following remain deliberately excluded:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, multiple-parent
|
||||
inheritance, and cross-scope references;
|
||||
- named overlay inheritance;
|
||||
- parser-specific or custom structural-decoding extension APIs;
|
||||
- generic dynamic-subtree conversion and module-registry APIs;
|
||||
- automatic schema migrations;
|
||||
- hot reload, runtime resource replacement, and remote resource validation;
|
||||
- a bundled CLI framework or application commands; and
|
||||
- configuration fingerprints.
|
||||
|
||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||
explanation commands around the library. Broadening these boundaries requires
|
||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||
|
||||
Effective configuration diffs are implemented over frozen successful-load
|
||||
inspections; their current comparison and disclosure contract is in the
|
||||
[configuration contract](../config.md#effective-configuration-diffs), with
|
||||
consumer workflows in the [adoption guide](../consumers/README.md#effective-configuration-diffs).
|
||||
Fingerprints remain deferred.
|
||||
|
||||
Built-in sibling inheritance using `extends` is implemented as described in the
|
||||
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
||||
inheritance between selected overlay files, which remains excluded above.
|
||||
|
||||
## Possible source-version follow-ups
|
||||
|
||||
Independent collection or subtree specification policies were outside the
|
||||
completed shared source-version feature and remain possible future work, with
|
||||
no committed delivery plan. Current [source-version checks](../config.md#shared-source-version-checks)
|
||||
use one application-owned policy across participating documents. Reconsider
|
||||
independent policies if concrete consumer needs justify a separate design
|
||||
decision; this is not a permanent prohibition. The original request and its
|
||||
selected scope remain at `e4270c9:docs/roadmap/promptkit-feature-request.md` in
|
||||
Git history. Existing sequence-composition and cross-scope inheritance deferrals
|
||||
above also remain unchanged.
|
||||
|
||||
## Deferred performance experiments
|
||||
|
||||
The completed performance audit left these internal optimization candidates for
|
||||
separate investigation:
|
||||
|
||||
- branch-local explanation replay instead of the current whole-tree replay;
|
||||
- redesigning the private metadata-copy handoff while preserving detached public
|
||||
records;
|
||||
- caching schema descriptors across loads; and
|
||||
- indexing complex contributor-trace membership while preserving its equality
|
||||
rules and compact linked traces.
|
||||
|
||||
These are not committed implementation work. Reconsider them with evidence that
|
||||
the benefit justifies the ownership, retained-memory, or equivalence risks. The
|
||||
completed audit and implementation evidence are preserved in Git history at
|
||||
`8e91a2b` under `docs/roadmap/`.
|
||||
381
docs/roadmap/discovery.md
Normal file
381
docs/roadmap/discovery.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Composable Configuration Discovery Roadmap
|
||||
|
||||
Status: proposal with OD-1 through OD-5 and the final review clarifications
|
||||
settled and integrated below.
|
||||
This document describes future work, not an implemented contract. A staged
|
||||
implementation plan will follow review of the complete roadmap.
|
||||
|
||||
## Purpose And Ownership
|
||||
|
||||
Make ordinary file-based loading concise for applications that want conventional
|
||||
configuration locations. ConfigKit supplies useful built-in candidate lists and
|
||||
an ordered selection operation. Consumers continue to own main-file search
|
||||
conventions: they choose which lists apply, their order, custom candidates, and
|
||||
whether a configuration file is required.
|
||||
|
||||
A consumer can select one preset for a simple application or compose several
|
||||
presets and custom path lists. The application supplies an explicit slug where
|
||||
needed to construct conventional paths. ConfigKit does not infer application
|
||||
identity from the executable name or enable discovery for existing explicit
|
||||
sources.
|
||||
|
||||
Discovery selects one source for the existing loading lifecycle. It does not
|
||||
create additional configuration tiers or merge every matching file. Existing
|
||||
layered loading remains available when consumers intentionally combine sources.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
The [source contract](../config.md#sources) provides explicit host files, native
|
||||
filesystem files, bytes, readers, mappings, and `NoSource()`. A missing explicit
|
||||
file fails. The zero source is invalid. There is no conventional main-file search.
|
||||
|
||||
[Ordinary loading](../config.md#single-source-loading) infers a selected overlay's
|
||||
filesystem context from the main source. `NoSource()` currently rejects enabled
|
||||
imports, active overlays, and filesystem context. In
|
||||
[layered loading](../config.md#layered-loading), an absent main input can coexist
|
||||
with underlays; a selected overlay requires an explicit `OverlayFilesystem`.
|
||||
These distinctions matter when an optional search finds nothing.
|
||||
|
||||
The [architecture policy](../policy/architecture.md#responsibility-boundaries)
|
||||
assigns search conventions to consumers and currently excludes implicit main-file
|
||||
discovery. The addition should clarify that consumers may explicitly select
|
||||
library-provided search conventions. Consumer ownership remains unchanged.
|
||||
|
||||
## Agreed Direction
|
||||
|
||||
### Filename Groups, Directory Searches, And Search Presets
|
||||
|
||||
Organize discovery around three composable descriptions. These are ordered lists:
|
||||
their order determines precedence.
|
||||
|
||||
**Filename groups** provide reusable, ordered filename templates. The built-in
|
||||
groups are:
|
||||
|
||||
| Group | Filenames, in order |
|
||||
| --- | --- |
|
||||
| Named | `<appname>.yml`, `<appname>.yaml` |
|
||||
| Generic | `config.yml`, `config.yaml`, `main.yml`, `main.yaml` |
|
||||
|
||||
`<appname>` is the application slug supplied by the consumer.
|
||||
|
||||
**Directory searches** pair a directory with an ordered list of filename groups.
|
||||
The default selection is `[Named, Generic]`, producing the six filenames above
|
||||
in that order. A directory search may select different groups or reverse their
|
||||
precedence, such as `[Generic, Named]`, while retaining each group's internal
|
||||
order. Consumers can also supply custom filenames, subject to the literal
|
||||
single-component rules under OD-5 below.
|
||||
|
||||
**Search presets** bundle ordered directory searches. Consumers can select a
|
||||
complete preset for the simple case or compose presets, custom directory searches,
|
||||
and explicit file candidates in their desired order. Explicit file candidates
|
||||
are literal paths and receive no filename expansion.
|
||||
|
||||
### Ordered Expansion And Concatenation
|
||||
|
||||
The selection model is **ordered expansion and concatenation**, followed by
|
||||
first-match selection:
|
||||
|
||||
1. Expand the selected presets into directory searches in their supplied order.
|
||||
2. For each directory search, concatenate its selected filename groups in order,
|
||||
substitute the application slug where needed, and join each filename to that
|
||||
directory.
|
||||
3. Concatenate the resulting candidate lists, preserving the positions of custom
|
||||
directory searches and explicit file candidates.
|
||||
4. Try candidates in order, stopping at the first selected file or an error as
|
||||
described below.
|
||||
|
||||
Directory priority comes before filename priority: exhaust one directory's
|
||||
filenames before trying the next directory. When several directories share a
|
||||
filename list, their expansion is a Cartesian product traversed directory first.
|
||||
Different directories may select different lists, so ordered expansion and
|
||||
concatenation describes the complete model. Set union does not express its
|
||||
precedence semantics.
|
||||
|
||||
The eventual documentation should explain this model using the applicable
|
||||
mathematical nomenclature: ordered lists, ordered concatenation, and Cartesian
|
||||
product where a shared filename list applies. Retain these names in relevant
|
||||
implementation comments where they help readers understand the operations. The
|
||||
model describes ordering without requiring eager construction of every candidate.
|
||||
|
||||
### Unix System Preset
|
||||
|
||||
The agreed Unix system preset is:
|
||||
|
||||
| Directory, in search order | Filename groups, in order |
|
||||
| --- | --- |
|
||||
| `/usr/local/etc/<appname>` | `[Named, Generic]` |
|
||||
| `/etc/<appname>` | `[Named, Generic]` |
|
||||
| `/etc` | `[Named]` |
|
||||
|
||||
This produces 14 candidates: six in each application-specific directory, then
|
||||
`/etc/<appname>.yml` and `/etc/<appname>.yaml`. For example,
|
||||
`/usr/local/etc/<appname>/main.yaml` precedes
|
||||
`/etc/<appname>/<appname>.yml`. Selecting only `Named` for `/etc` is an ordinary
|
||||
directory-search description; the selection algorithm needs no special exception.
|
||||
|
||||
### Additional Presets (OD-1 — Settled)
|
||||
|
||||
Provide the following independently selectable presets alongside Unix system.
|
||||
Within each preset, search directories in the order shown:
|
||||
|
||||
| Preset | Directory | Filename groups, in order |
|
||||
| --- | --- | --- |
|
||||
| User configuration | `<user-config-dir>/<appname>` | `[Named, Generic]` |
|
||||
| User configuration | `<user-config-dir>` | `[Named]` |
|
||||
| Home directory | `<home>/.<appname>` | `[Named, Generic]` |
|
||||
| Home directory | `<home>` | `[Named]` |
|
||||
| Working directory | Current working directory | `[Named]` |
|
||||
|
||||
Resolve the user-configuration base with
|
||||
[`os.UserConfigDir`](https://pkg.go.dev/os#UserConfigDir), the home base with
|
||||
[`os.UserHomeDir`](https://pkg.go.dev/os#UserHomeDir), and the working-directory
|
||||
base with [`os.Getwd`](https://pkg.go.dev/os#Getwd). Adopt the Go helpers'
|
||||
platform-specific directory and environment behavior, including their errors;
|
||||
the settled OD-2 rules below govern how discovery handles resolution errors.
|
||||
|
||||
The goal is to support familiar conventions through idiomatic Go. This feature
|
||||
does not add a separate XDG compatibility layer or XDG system preset. A selected
|
||||
user-configuration base has no additional hard-coded `<home>/.config` fallback.
|
||||
The Home directory preset is a separate, explicit choice.
|
||||
|
||||
The working-directory preset searches only that directory and uses application-named
|
||||
files; it does not walk ancestor directories. The consumer chooses precedence
|
||||
between all presets. Selecting one does not automatically select any others.
|
||||
|
||||
### Illustrative Consumer API
|
||||
|
||||
Names below are illustrative and are not implemented Go API:
|
||||
|
||||
```go
|
||||
// A simple system-wide application.
|
||||
source := configkit.SearchConfig("myapp", configkit.UnixSystemDirectories())
|
||||
```
|
||||
|
||||
```go
|
||||
// A consumer-selected working-directory, user, then system search.
|
||||
source := configkit.SearchConfig(
|
||||
"myapp",
|
||||
configkit.WorkingDirectory(),
|
||||
configkit.UserConfigDirectory(),
|
||||
configkit.UnixSystemDirectories(),
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
// Explicit custom candidates followed by a selected conventional fallback.
|
||||
source := configkit.SearchConfig(
|
||||
"myapp",
|
||||
configkit.Paths("/run/myapp/config.yml", "/opt/myapp/config.yaml"),
|
||||
configkit.UnixSystemDirectories(),
|
||||
)
|
||||
```
|
||||
|
||||
Use the resulting source with ordinary loading or as a layered main input. Custom
|
||||
searches and file lists participate at their specified position; they do not
|
||||
silently append unselected presets. Reordering presets or their directory
|
||||
searches changes search priority.
|
||||
|
||||
### Selection And Failure Behavior
|
||||
|
||||
| Situation | Selected behavior |
|
||||
| --- | --- |
|
||||
| Candidate is absent | Continue to the next candidate. |
|
||||
| A candidate file is selected | Stop searching and load that file. |
|
||||
| Candidate is inaccessible, unreadable, or not an accepted file type | Fail using normal source diagnostics. |
|
||||
| Selected file fails parsing, version checks, composition, decoding, or validation | Fail without trying another candidate. |
|
||||
| Entire search has no match | Absence is permitted by default; follow the shared absent-main lifecycle settled under OD-3 below. |
|
||||
| Entire search has no match and is required | Fail with a diagnostic explaining the unsuccessful search. |
|
||||
| Caller explicitly uses `File(path)` | Retain existing exclusive-file behavior, including errors for a missing file. |
|
||||
|
||||
Requiredness applies to the complete search, not to each preset or directory.
|
||||
Permitting an absent file does not waive application version, schema, or domain
|
||||
requirements; the remaining lifecycle may still reject an incomplete configuration.
|
||||
|
||||
Perform discovery before parsing the chosen main file. Do not implement fallback
|
||||
by repeatedly running complete loads. Defaults, application resolution, secret
|
||||
lookup, and final validation retain their existing lifecycle execution rules.
|
||||
Once a file has been selected, later failure does not restart discovery.
|
||||
|
||||
### Construction And Filesystem Behavior
|
||||
|
||||
Constructors describe the search without filesystem access. Resolve dynamic base
|
||||
directories and access candidate files during loading, so reusable descriptions
|
||||
can observe changes on subsequent loads. Do not change the process working
|
||||
directory or environment.
|
||||
|
||||
Reuse the host-file acquisition rules for selected files, including regular-file
|
||||
checks, symlink handling, resource ownership, and source limits. The feature does
|
||||
not require a new filesystem traversal engine, recursive directory search, or
|
||||
new file-size limits. Candidate filenames and groups are bounded by their
|
||||
explicit descriptions, rather than by scanning unrelated directories.
|
||||
|
||||
The exact Go names, constructors, and representation of search-wide options
|
||||
belong in the implementation plan. Keep the public surface root-owned and keep
|
||||
the single-preset case concise.
|
||||
|
||||
### Unavailable Bases And Platform Applicability (OD-2 — Settled)
|
||||
|
||||
Resolve directory bases only as the ordered search reaches them. Once an earlier
|
||||
candidate is selected, later searches do not resolve their bases. Static invalid
|
||||
search arguments still fail ordinary option validation before source acquisition.
|
||||
An explicitly empty filename selection skips directory resolution as specified
|
||||
under OD-4; it has no candidates to locate.
|
||||
|
||||
If a selected directory's base cannot be resolved, stop and report the error.
|
||||
A missing directory or candidate file beneath a resolved base allows the search
|
||||
to continue; a directory-resolution error does not. This distinction also applies
|
||||
to optional searches: permitting no match does not suppress errors encountered
|
||||
while searching. Preserve the agreed errors for inaccessible or unreadable
|
||||
candidates.
|
||||
|
||||
Treat using the Unix system preset on an unsupported platform as an explicit
|
||||
configuration error when that preset is reached. Specify its supported platforms
|
||||
in the implementation plan. Do not translate the Unix preset into a different
|
||||
platform's convention. User, home, and working-directory presets use their native
|
||||
Go directory helpers.
|
||||
|
||||
This keeps search priority predictable when directory resolution fails. Consumers
|
||||
select the presets appropriate to their application and environment; this proposal
|
||||
does not add an option to skip unavailable bases or unsupported presets.
|
||||
|
||||
### No Match, Imports, And Selected Overlays (OD-3 — Settled)
|
||||
|
||||
Use a shared absent-main lifecycle for optional no-match and explicit `NoSource()`.
|
||||
Allow imports to be enabled when the main file is absent: there are no main-file
|
||||
import declarations to process, and enablement does not introduce automatic
|
||||
directory scanning. Other actual sources retain their normal import behavior.
|
||||
In layered loads, process underlays normally and retain the existing explicit
|
||||
`OverlayFilesystem` requirement for an active overlay.
|
||||
|
||||
For ordinary loads with an active overlay and no main file, require an explicit
|
||||
filesystem context to locate the overlay. Do not infer an overlay root from the
|
||||
first or last searched directory. A missing selected overlay remains an error;
|
||||
absence of a main file does not silently disable a requested overlay.
|
||||
|
||||
For an absent main input, a host filesystem context specifying only `Root` is
|
||||
sufficient to locate the overlay; no main-file `BaseDir` is needed. If discovery
|
||||
selects a host file, retain the normal file-context rules: the selected file
|
||||
determines its base directory, an explicit `BaseDir` is rejected, and the file
|
||||
must be contained within any supplied root. This allows one root-only context
|
||||
to work with both selected and absent main inputs, subject to those existing
|
||||
root and containment checks. An invalid context is an error, not a reason to
|
||||
resume discovery.
|
||||
|
||||
Defaults, CLI overrides, application resolution, and validation continue through
|
||||
the normal lifecycle. Main-version checking receives an absent main input and
|
||||
may reject it under the application's existing policy. Create no synthetic empty
|
||||
document or source record for the absent main file.
|
||||
|
||||
This deliberately relaxes the current `NoSource()` restrictions on imports and
|
||||
on overlays with explicit context. Both ways of obtaining an absent main input
|
||||
have the same behavior. Implementation must cover this lifecycle change as well
|
||||
as candidate selection.
|
||||
|
||||
### Empty Composition And Implicit Preset Selection (OD-4 — Settled)
|
||||
|
||||
Require explicit preset or search selection to activate a convention. Supplying
|
||||
no presets or custom searches produces no candidates and activates no implicit
|
||||
fallback. Empty custom path lists and explicitly empty filename-group selections
|
||||
also contribute no candidates. An empty component does not prevent later
|
||||
components from contributing candidates.
|
||||
|
||||
A directory search with an explicitly empty filename selection does not resolve
|
||||
its directory base. Static argument validation still applies. This gives empty
|
||||
selections their no-candidate meaning without introducing directory-resolution
|
||||
errors for a search that cannot select a file.
|
||||
|
||||
If the complete search is empty, an optional search follows the absent-main
|
||||
lifecycle settled under OD-3; a required search fails as exhausted. Empty lists
|
||||
are valid, supporting consumers that construct their searches programmatically.
|
||||
|
||||
Distinguish omission from an explicitly empty filename selection for a directory
|
||||
search. Omitting the selection uses the agreed `[Named, Generic]` default;
|
||||
explicitly selecting an empty list means no filenames. The eventual API must
|
||||
represent both intentions clearly. Presets retain their specified filename groups.
|
||||
|
||||
Selecting a single preset keeps the simple case concise. Search-wide requiredness
|
||||
provides the check for consumers that must find a configuration file.
|
||||
|
||||
### Slugs And Custom Path Interpretation (OD-5 — Settled)
|
||||
|
||||
Require a nonempty single-component application slug whenever a selected search
|
||||
uses it to construct a directory or filename. Reject path separators, `.` and
|
||||
`..`, and forms unusable as that component on the target platform. Preserve
|
||||
spelling and case; do not trim, lowercase, or impose a kebab-case naming scheme.
|
||||
Validation keeps the slug from changing the intended directory structure without
|
||||
imposing a separate naming style.
|
||||
|
||||
Searches that do not use the slug do not require one. Examples include explicit
|
||||
file candidates and a custom directory searched with only Generic filenames.
|
||||
|
||||
Treat custom paths as literal host paths. Resolve relative paths against the
|
||||
working directory at load time. Do not expand shell variables, `~`, or wildcard
|
||||
syntax. Consumers can construct expanded paths themselves; User and Home presets
|
||||
provide the common directory-resolution conveniences. These rules keep custom
|
||||
paths consistent with explicit file loading.
|
||||
|
||||
Custom filenames within directory searches are literal single filename
|
||||
components. They cannot be absolute paths or contain subdirectory components;
|
||||
use custom directory searches or explicit file candidates for those layouts.
|
||||
They receive no application-name placeholder expansion. The Named group supplies
|
||||
application-name expansion, while consumers may construct custom filenames
|
||||
themselves before supplying them.
|
||||
|
||||
### Placement, Provenance, And Existing Features
|
||||
|
||||
The selected file participates as the actual file source. Preserve its physical
|
||||
identity, effective placement, source origin, and passive resource context.
|
||||
Relative imports and relative application resources use the selected file's
|
||||
normal context. Discovery does not relocate overlay or CLI coordinates.
|
||||
|
||||
Missing candidates are not acquired sources and do not create source IDs,
|
||||
configuration history, or explicit input presence. An entirely absent search
|
||||
contributes no synthetic empty configuration file. Diagnostics should distinguish
|
||||
an exhausted required search from a failed candidate. Existing selected-source
|
||||
inspection remains the foundation; a new public per-attempt trace is not needed
|
||||
for this proposal.
|
||||
|
||||
Preserve collection policies, inheritance, source-version rules, CLI ordering,
|
||||
final validation, and frozen inspection after selection. Search-list order does
|
||||
not change precedence among underlays, the main input, an overlay, and CLI values.
|
||||
An explicitly selected overlay is not another main-file fallback candidate.
|
||||
|
||||
## Work Needed From The Baseline
|
||||
|
||||
The subsequent implementation plan should cover:
|
||||
|
||||
- Define filename-group, directory-search, preset, and search-source representations,
|
||||
slug validation, and a search-wide requiredness option. Keep construction
|
||||
separate from acquisition and the simple preset selection concise. Distinguish
|
||||
omitted and explicitly empty directory-search filename selections.
|
||||
- Implement ordered expansion and concatenation for presets, filename groups,
|
||||
custom searches, and explicit file candidates, followed by first-match selection
|
||||
through existing file access. Resolve each source once per consumption before
|
||||
entering its parse and composition lifecycle.
|
||||
- Integrate selected and absent outcomes with ordinary and layered main inputs,
|
||||
placement, imports, application version checking, and overlay contexts according
|
||||
to OD-3, including root-only host contexts for absent main inputs and normal
|
||||
file-context checks when a file is selected. Preserve ownership and close
|
||||
acquired handles on success and failure.
|
||||
- Preserve real selected-source metadata and safe diagnostics without treating
|
||||
absent candidates as configuration contributions.
|
||||
- Exercise preset, directory, filename-group, and filename ordering, reversed
|
||||
group precedence, the Unix system preset's 14 candidates and named-only `/etc`
|
||||
search, custom-list composition, both filename extensions, no-match behavior,
|
||||
empty components and searches, omitted versus empty filename selections,
|
||||
skipped directory resolution for empty filename selections, slug-dependent and
|
||||
slug-independent searches, literal custom paths and filename components,
|
||||
required searches, unavailable preset bases, errors that must stop fallback,
|
||||
repeated loads, and untouched explicit `File`
|
||||
behavior. Cover imports, overlays, underlays, defaults, CLI, source versions,
|
||||
source contexts, and source-list/inspection evidence at their boundaries.
|
||||
- Clarify the architecture policy's consumer-selected discovery boundary. Record
|
||||
any warranted durable ownership decision in an ADR; keep preset filenames and
|
||||
API mechanics in their canonical contract when implemented. Update consumer
|
||||
examples and the configuration contract when the feature lands. Explain the
|
||||
three-part discovery model and its mathematical terminology in the eventual
|
||||
documentation, with simple preset selection and custom composition examples.
|
||||
|
||||
This roadmap does not change implementation, policy, or ADRs by itself. Coordinate
|
||||
implementation with the ongoing sequence integration audit rather than assuming
|
||||
its in-progress changes are part of a fixed implementation baseline.
|
||||
85
docs/roadmap/future.md
Normal file
85
docs/roadmap/future.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 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. Recursive body merging for identified records
|
||||
|
||||
Status: deferred. The implemented
|
||||
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
||||
replace a complete matched record. Recursive merging of fields inside matching
|
||||
records remains a separate candidate and would revisit the whole-record update
|
||||
boundary selected by [ADR 0017](../adr/0017-collection-sequence-policy.md).
|
||||
|
||||
Consider this only in response to a concrete consumer need. A proposal would need
|
||||
to define conflicts for overlapping body fields, removal of omitted fields,
|
||||
presence and ordering, and how provenance, history, sensitivity, and resource
|
||||
context follow partially retained records. Any change requires a new decision and
|
||||
implementation plan; this entry does not alter current upsert 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?
|
||||
@@ -19,6 +19,28 @@ func ExampleLoad() {
|
||||
// Output: {true 3} <nil>
|
||||
}
|
||||
|
||||
func ExampleLoad_sequenceDeclaration() {
|
||||
type Item struct {
|
||||
ID string `yaml:"id"`
|
||||
}
|
||||
type Config struct {
|
||||
Items []Item `yaml:"items"`
|
||||
}
|
||||
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
|
||||
sequences:
|
||||
- destination: [items]
|
||||
key: id
|
||||
items:
|
||||
- id: prompt
|
||||
`)))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Items[0].ID)
|
||||
// Output: prompt
|
||||
}
|
||||
|
||||
func ExampleNoSource() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
@@ -154,8 +176,15 @@ func ExampleOverride() {
|
||||
})
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
||||
Overrides: overrides,
|
||||
Definition: configkit.Definition[Config]{
|
||||
Defaults: func() Config { return Config{Workers: 4} },
|
||||
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
|
||||
configkit.Default(resolution, configkit.Keys("workers"), &configuration.Workers, 100,
|
||||
"preserve an explicitly supplied worker count")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Overrides: overrides,
|
||||
})
|
||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||
// Output: 0 --workers <nil>
|
||||
|
||||
67
examples/consumers/prompt_catalog.go
Normal file
67
examples/consumers/prompt_catalog.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// PromptCatalogConfig is an application-owned prompt collection shape.
|
||||
type PromptCatalogConfig struct {
|
||||
Prompts []PromptRecord `yaml:"prompts"`
|
||||
}
|
||||
|
||||
// PromptRecord keeps its identity and message meaning in the consumer.
|
||||
type PromptRecord struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Messages []string `yaml:"messages"`
|
||||
Resource string `yaml:"resource"`
|
||||
}
|
||||
|
||||
// LoadPromptCatalog discovers explicitly registered prompt records in the main
|
||||
// tier and replaces the lower-priority default collection as a whole.
|
||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.File(mainPath),
|
||||
Imports: true,
|
||||
}, configkit.LayeredOptions[PromptCatalogConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||
}}}},
|
||||
ImportDefaults: configkit.ImportDefaults{
|
||||
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||
},
|
||||
Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
|
||||
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
|
||||
Imports: true, Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||
return configkit.Definition[PromptCatalogConfig]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "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")
|
||||
}
|
||||
}
|
||||
135
examples/consumers/prompt_catalog_test.go
Normal file
135
examples/consumers/prompt_catalog_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_identifiedPromptCatalog() {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(result.Config.Prompts))
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
ids = append(ids, prompt.ID)
|
||||
}
|
||||
emptySearches := 0
|
||||
for _, source := range result.Sources() {
|
||||
for _, selection := range source.ImportSelections {
|
||||
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
||||
emptySearches++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println(ids, emptySearches, err)
|
||||
// Output: [weather curated welcome main] 1 <nil>
|
||||
}
|
||||
|
||||
func Example_requiredPromptCatalog() {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Prompts[0].ID, err)
|
||||
// Output: required <nil>
|
||||
}
|
||||
|
||||
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load prompt catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Prompts) != 4 {
|
||||
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
||||
}
|
||||
ids := []string{"weather", "curated", "welcome", "main"}
|
||||
for index, id := range ids {
|
||||
if result.Config.Prompts[index].ID != id {
|
||||
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
||||
}
|
||||
}
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
if prompt.ID == "default" {
|
||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
||||
}
|
||||
if prompt.ID == "ignored" {
|
||||
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
var itemImports, helperImports int
|
||||
for _, source := range result.Sources() {
|
||||
switch filepath.Base(source.Path) {
|
||||
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||
t.Fatalf("item source placement = %#v", source)
|
||||
}
|
||||
itemImports++
|
||||
case "curated-id.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeMapping || source.ImportedFrom == nil {
|
||||
t.Fatalf("helper source = %#v", source)
|
||||
}
|
||||
helperImports++
|
||||
}
|
||||
}
|
||||
if itemImports != 3 || helperImports != 1 {
|
||||
t.Fatalf("item imports = %d, helper imports = %d, sources = %#v", itemImports, helperImports, result.Sources())
|
||||
}
|
||||
if result.Config.Prompts[1].ID != "curated" {
|
||||
t.Fatalf("helper did not provide prompt ID: %#v", result.Config.Prompts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
|
||||
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
|
||||
info, ok := result.Metadata().Lookup(resourcePath)
|
||||
if !ok || len(info.Origin.Locations) != 1 {
|
||||
t.Fatalf("resource origin = %#v, %v", info, ok)
|
||||
}
|
||||
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
|
||||
t.Fatalf("resource source context = %#v, %v", context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
|
||||
if err != nil || string(contents) != "Welcome resource text.\n" {
|
||||
t.Fatalf("mounted resource = %q, %v", contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
|
||||
t.Fatalf("simple required prompt = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/prompts/0/messages" {
|
||||
t.Fatalf("consumer domain validation error = %v", err)
|
||||
}
|
||||
}
|
||||
134
examples/consumers/sequence_catalog.go
Normal file
134
examples/consumers/sequence_catalog.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// SequenceCatalogConfig is a source-backed prompt catalog with an application
|
||||
// supplied run label and a value derived from that label during resolution.
|
||||
type SequenceCatalogConfig struct {
|
||||
RunLabel string `yaml:"run_label"`
|
||||
ResolvedLabel string `yaml:"resolved_label"`
|
||||
Prompts []SequenceCatalogEntry `yaml:"prompts"`
|
||||
}
|
||||
|
||||
// SequenceCatalogEntry keeps prompt meaning, resources, and credentials in the
|
||||
// consuming application while ConfigKit handles collection assembly.
|
||||
type SequenceCatalogEntry struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Messages []string `yaml:"messages"`
|
||||
Resource string `yaml:"resource"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
|
||||
// LoadSequenceCatalog assembles imported main fragments over a file-backed
|
||||
// underlay, applies one selected collection overlay, then resolves and validates
|
||||
// the resulting application configuration.
|
||||
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
||||
root := filepath.Dir(mainPath)
|
||||
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||
definition := configkit.Definition[SequenceCatalogConfig]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
},
|
||||
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
||||
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
||||
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization,
|
||||
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
||||
return nil
|
||||
},
|
||||
Validate: validateSequenceCatalog,
|
||||
}
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: filesystem.File(filepath.Base(mainPath)),
|
||||
Imports: true,
|
||||
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: filesystem.File("underlay.yml"),
|
||||
}}}},
|
||||
Overlay: overlayName,
|
||||
OverlayFilesystem: filesystemContext,
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("run_label"), Value: runLabel, Flag: "--run-label",
|
||||
}},
|
||||
Definition: definition,
|
||||
})
|
||||
}
|
||||
|
||||
// ReadSequenceCatalogResource follows the final prompt field's source context,
|
||||
// including when record inheritance retained a resource from an underlay.
|
||||
func ReadSequenceCatalogResource(result *configkit.Result[SequenceCatalogConfig], promptID string) (string, error) {
|
||||
if result == nil {
|
||||
return "", errors.New("sequence catalog result is nil")
|
||||
}
|
||||
for index, prompt := range result.Config.Prompts {
|
||||
if prompt.ID != promptID {
|
||||
continue
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(index).Key("resource")
|
||||
info, found := result.Metadata().Lookup(resourcePath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return "", errors.New("prompt resource has no single declaring source")
|
||||
}
|
||||
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return "", errors.New("prompt resource source context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.Resource))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read prompt resource: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("prompt %q is unavailable", promptID)
|
||||
}
|
||||
|
||||
func validateSequenceCatalog(configuration *SequenceCatalogConfig, 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")
|
||||
}
|
||||
}
|
||||
|
||||
// NamespacedMapCatalogConfig demonstrates typed application maps whose
|
||||
// independent prompt sequences use root and dynamic namespace registrations.
|
||||
type NamespacedMapCatalogConfig struct {
|
||||
Prompts []map[string]any `yaml:"prompts"`
|
||||
Collections map[string]map[string]any `yaml:"collections"`
|
||||
}
|
||||
|
||||
// LoadNamespacedMapCatalog applies the same scalar identity constraint to one
|
||||
// root collection and each namespace's independently scoped collection.
|
||||
func LoadNamespacedMapCatalog(source configkit.Source) (*configkit.Result[NamespacedMapCatalogConfig], error) {
|
||||
selector := configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}
|
||||
return configkit.Load(source, configkit.Options[NamespacedMapCatalogConfig]{
|
||||
Definition: configkit.Definition[NamespacedMapCatalogConfig]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
configkit.SequenceIdentityMatching(selector, "id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
239
examples/consumers/sequence_catalog_test.go
Normal file
239
examples/consumers/sequence_catalog_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_sequenceCatalog() {
|
||||
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||
restoreOld, restoreNew := preserveEnvironment(oldSecret), preserveEnvironment(newSecret)
|
||||
defer restoreOld()
|
||||
defer restoreNew()
|
||||
_ = os.Setenv(oldSecret, "disposable-old-example-credential")
|
||||
_ = os.Setenv(newSecret, "disposable-new-example-credential")
|
||||
|
||||
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "preview")
|
||||
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)
|
||||
}
|
||||
shared := configkit.Keys("prompts").Index(1).Key("name")
|
||||
info, _ := result.Metadata().Lookup(shared)
|
||||
resource, resourceErr := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||
fmt.Println(ids, result.Config.ResolvedLabel, info.Explicit, len(result.Metadata().History(shared)), resource, resourceErr == nil, err)
|
||||
// Output: [foundation shared welcome concise extra] for:preview true 2 Foundation resource text.
|
||||
// true <nil>
|
||||
}
|
||||
|
||||
func TestSequenceCatalogSelectedOverlayOperations(t *testing.T) {
|
||||
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||
t.Setenv(oldSecret, "sequence-catalog-old-canary")
|
||||
t.Setenv(newSecret, "sequence-catalog-new-canary")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantIDs []string
|
||||
shared *consumers.SequenceCatalogEntry
|
||||
}{
|
||||
{name: "append", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}},
|
||||
{name: "upsert", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}, shared: &consumers.SequenceCatalogEntry{ID: "shared", Name: "Shared replacement", Messages: []string{"Replacement message"}, Credential: configkit.SecretEnv(newSecret)}},
|
||||
{name: "replace", wantIDs: []string{"replacement", "last"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), test.name, "test")
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load selected overlay: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotIDs := make([]string, 0, len(result.Config.Prompts))
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
gotIDs = append(gotIDs, prompt.ID)
|
||||
}
|
||||
if strings.Join(gotIDs, ",") != strings.Join(test.wantIDs, ",") {
|
||||
t.Fatalf("prompt order = %v, want %v", gotIDs, test.wantIDs)
|
||||
}
|
||||
if test.shared == nil {
|
||||
return
|
||||
}
|
||||
shared := result.Config.Prompts[1]
|
||||
if shared.ID != test.shared.ID || shared.Name != test.shared.Name || len(shared.Messages) != 1 ||
|
||||
shared.Messages[0] != test.shared.Messages[0] || shared.Description != "" || shared.Resource != "" || shared.Credential.EnvName() != newSecret {
|
||||
t.Fatalf("upsert did not replace the complete matched record: %#v", shared)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequenceCatalogInspectionTracksResourcesAndSecretHistory(t *testing.T) {
|
||||
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||
const oldCanary = "sequence-catalog-old-canary"
|
||||
const newCanary = "sequence-catalog-new-canary"
|
||||
t.Setenv(oldSecret, oldCanary)
|
||||
t.Setenv(newSecret, newCanary)
|
||||
|
||||
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "history")
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load sequence catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(3).Key("resource")
|
||||
resourceInfo, found := result.Metadata().Lookup(resourcePath)
|
||||
if !found || len(resourceInfo.Origin.Locations) != 1 || len(resourceInfo.Origin.Inheritance) != 1 {
|
||||
t.Fatalf("inherited resource inspection = %#v, %v", resourceInfo, found)
|
||||
}
|
||||
resource, err := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||
if err != nil || resource != "Foundation resource text.\n" {
|
||||
t.Fatalf("resource from inherited source context = %q, %v", resource, err)
|
||||
}
|
||||
if len(result.Metadata().History(configkit.Keys("resolved_label"))) == 0 {
|
||||
t.Fatal("recorded CLI-driven resolution was absent from final inspection")
|
||||
}
|
||||
credentialPath := configkit.Keys("prompts").Index(1).Key("credential")
|
||||
credentialHistory := result.Metadata().History(credentialPath)
|
||||
if len(credentialHistory) == 0 {
|
||||
t.Fatal("upsert did not retain the replaced secret reference in history")
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := result.WriteExplanation(&explanation, credentialPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(explanation.String(), oldCanary) || strings.Contains(explanation.String(), newCanary) {
|
||||
t.Fatalf("secret explanation exposed a credential value: %s", explanation.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedTypedMapIdentityScopesAreIndependent(t *testing.T) {
|
||||
source := configkit.Bytes("namespaced maps", []byte(`
|
||||
prompts:
|
||||
- id: shared
|
||||
text: root prompt
|
||||
collections:
|
||||
team:
|
||||
prompts:
|
||||
- id: shared
|
||||
text: team prompt
|
||||
operations:
|
||||
prompts:
|
||||
- id: shared
|
||||
text: operations prompt
|
||||
`))
|
||||
result, err := consumers.LoadNamespacedMapCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Prompts[0]["text"] != "root prompt" ||
|
||||
result.Config.Collections["team"]["prompts"].([]any)[0].(map[string]any)["text"] != "team prompt" ||
|
||||
result.Config.Collections["operations"]["prompts"].([]any)[0].(map[string]any)["text"] != "operations prompt" {
|
||||
t.Fatalf("typed map collections = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedTypedMapIdentityFindsCollectionsIntroducedByInheritance(t *testing.T) {
|
||||
result, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("inherited namespace", []byte(`
|
||||
collections:
|
||||
source:
|
||||
prompts:
|
||||
- id: shared
|
||||
text: inherited prompt
|
||||
copied:
|
||||
extends: source
|
||||
`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied := result.Config.Collections["copied"]["prompts"].([]any)
|
||||
if len(copied) != 1 || copied[0].(map[string]any)["text"] != "inherited prompt" {
|
||||
t.Fatalf("inherited typed-map collection = %#v", result.Config.Collections["copied"])
|
||||
}
|
||||
}
|
||||
|
||||
func Example_yamlDeclaredIdentityOnly() {
|
||||
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
prompts:
|
||||
- id: initial
|
||||
`)), configkit.Options[map[string]any]{
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
Value: []any{
|
||||
map[string]any{"id": "same"},
|
||||
map[string]any{"id": "same"},
|
||||
},
|
||||
Flag: "--prompts",
|
||||
}},
|
||||
})
|
||||
fmt.Println(len(result.Config["prompts"].([]any)), err)
|
||||
// Output: 2 <nil>
|
||||
}
|
||||
|
||||
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
prompts:
|
||||
- id: initial
|
||||
`)), configkit.Options[map[string]any]{
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
Value: []any{
|
||||
map[string]any{"id": "same"},
|
||||
map[string]any{"id": "same"},
|
||||
},
|
||||
Flag: "--prompts",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("YAML-only identity incorrectly became an application constraint: %v", err)
|
||||
}
|
||||
prompts := result.Config["prompts"].([]any)
|
||||
if len(prompts) != 2 || prompts[0].(map[string]any)["id"] != prompts[1].(map[string]any)["id"] {
|
||||
t.Fatalf("CLI result = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedTypedMapRejectsDuplicatesWithinOneCollection(t *testing.T) {
|
||||
_, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("duplicate namespace", []byte(`
|
||||
collections:
|
||||
team:
|
||||
prompts:
|
||||
- id: duplicate
|
||||
- id: duplicate
|
||||
`)))
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||
diagnostics := []configkit.Diagnostic(nil)
|
||||
if loadError != nil {
|
||||
diagnostics = loadError.Diagnostics()
|
||||
}
|
||||
t.Fatalf("duplicate namespaced identity error = %v (%#v)", err, diagnostics)
|
||||
}
|
||||
}
|
||||
19
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
19
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: replace
|
||||
imports:
|
||||
- dir: prompts
|
||||
exclude: [generated-ignored.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: featured
|
||||
pattern: [weather-*.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: optional
|
||||
prompts:
|
||||
- id: main
|
||||
name: Main authored prompt
|
||||
messages: [Use the main prompt.]
|
||||
4
examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
id: weather
|
||||
name: Weather forecast
|
||||
messages:
|
||||
- Describe today's forecast.
|
||||
9
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
9
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: replace
|
||||
prompts:
|
||||
- id: invalid
|
||||
name: Invalid prompt
|
||||
messages: []
|
||||
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This file does not match the application import defaults.
|
||||
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: helpers/curated-id.yml
|
||||
name: Curated summary
|
||||
messages:
|
||||
- Summarize the supplied content.
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: ignored
|
||||
name: Excluded generated prompt
|
||||
messages: [This record is excluded.]
|
||||
5
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
5
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
id: welcome
|
||||
name: Welcome
|
||||
messages:
|
||||
- Welcome the user.
|
||||
resource: welcome.txt
|
||||
1
examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
id: curated
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: required
|
||||
name: Required prompt
|
||||
messages: [Use the required prompt.]
|
||||
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Welcome resource text.
|
||||
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: prompts/required.yml
|
||||
required: true
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
prompts: []
|
||||
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
run_label: source-default
|
||||
configkit:
|
||||
imports:
|
||||
- dir: fragments
|
||||
pattern: '*.yml'
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Foundation resource text.
|
||||
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
id: welcome
|
||||
name: Welcome
|
||||
messages: [Welcome message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: concise
|
||||
extends: {id: foundation}
|
||||
name: Concise
|
||||
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: append
|
||||
prompts:
|
||||
- id: extra
|
||||
name: Extra
|
||||
messages: [Extra message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: replace
|
||||
prompts:
|
||||
- id: replacement
|
||||
name: Replacement
|
||||
messages: [Replacement message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
- id: last
|
||||
name: Last
|
||||
messages: [Last message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: upsert
|
||||
prompts:
|
||||
- id: shared
|
||||
name: Shared replacement
|
||||
messages: [Replacement message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET
|
||||
- id: extra
|
||||
name: Extra
|
||||
messages: [Extra message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: append
|
||||
prompts:
|
||||
- id: foundation
|
||||
name: Foundation
|
||||
description: Shared foundation instruction
|
||||
messages: [Foundation message]
|
||||
resource: foundation.txt
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
- id: shared
|
||||
name: Shared draft
|
||||
description: This field must not survive a whole-record replacement
|
||||
messages: [Draft message]
|
||||
resource: shared.txt
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
249
facade_final_sequence_identity_test.go
Normal file
249
facade_final_sequence_identity_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
||||
for _, phase := range []string{"defaults", "resolution"} {
|
||||
for _, component := range []any{"application-data", nil} {
|
||||
for _, duplicate := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/%v/duplicate=%t", phase, component, duplicate), func(t *testing.T) {
|
||||
items := []any{map[string]any{"extends": component}}
|
||||
if duplicate {
|
||||
items = append(items, map[string]any{"extends": component})
|
||||
}
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "extends")},
|
||||
Defaults: func() map[string]any {
|
||||
if phase == "defaults" {
|
||||
return map[string]any{"items": items}
|
||||
}
|
||||
return map[string]any{}
|
||||
},
|
||||
}
|
||||
if phase == "resolution" {
|
||||
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["items"] = items
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
loaded, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||
if duplicate {
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
diagnostics := loadErr.Diagnostics()
|
||||
if loaded != nil || len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != "/items" {
|
||||
t.Fatalf("duplicate effective payload diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("ordinary effective identity was interpreted as a directive: %v", err)
|
||||
}
|
||||
value := loaded.Config["items"].([]any)[0].(map[string]any)
|
||||
if actual, present := value["extends"]; !present || actual != component {
|
||||
t.Fatalf("effective payload = %#v", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
|
||||
identity := configkit.SequenceIdentityAt(configkit.Keys("items"), "id")
|
||||
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||
for _, shape := range []string{"duplicate", "missing", "non-scalar"} {
|
||||
t.Run(source+"/"+shape, func(t *testing.T) {
|
||||
items := finalIdentityRecords(shape)
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{identity},
|
||||
Defaults: func() map[string]any {
|
||||
if source == "defaults" {
|
||||
return map[string]any{"items": items}
|
||||
}
|
||||
return map[string]any{}
|
||||
},
|
||||
}
|
||||
options := configkit.Options[map[string]any]{Definition: definition}
|
||||
switch source {
|
||||
case "CLI":
|
||||
options.Overrides = []configkit.Override{{Path: configkit.Keys("items"), Value: items, Flag: "--items"}}
|
||||
case "resolution":
|
||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["items"] = items
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), options)
|
||||
if result != nil {
|
||||
t.Fatalf("invalid final identities returned a result: %#v", result.Config)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) != 1 {
|
||||
t.Fatalf("diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
diagnostic := loadErr.Diagnostics()[0]
|
||||
wantCode, wantPath := configkit.CodeSequenceInvalid, "/items/0/id"
|
||||
if shape == "duplicate" {
|
||||
wantCode, wantPath = configkit.CodeSequenceConflict, "/items"
|
||||
}
|
||||
if diagnostic.Code != wantCode || diagnostic.Path.String() != wantPath {
|
||||
t.Fatalf("diagnostic = %#v, want %s at %s", diagnostic, wantCode, wantPath)
|
||||
}
|
||||
if source == "CLI" && (diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1") {
|
||||
t.Fatalf("CLI identity diagnostic lost its source: %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
|
||||
selector := configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("items"),
|
||||
}
|
||||
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
name := map[string]string{"defaults": "team", "CLI": "cloud", "resolution": "orbit"}[source]
|
||||
path := configkit.Keys("collections", name, "items")
|
||||
items := finalIdentityRecords("duplicate")
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(selector, "id"),
|
||||
},
|
||||
Defaults: func() map[string]any {
|
||||
if source == "defaults" {
|
||||
return map[string]any{"collections": map[string]any{name: map[string]any{"items": items}}}
|
||||
}
|
||||
return map[string]any{}
|
||||
},
|
||||
}
|
||||
options := configkit.Options[map[string]any]{Definition: definition}
|
||||
switch source {
|
||||
case "CLI":
|
||||
options.Overrides = []configkit.Override{{Path: path, Value: items, Flag: "--items"}}
|
||||
case "resolution":
|
||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
||||
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), options)
|
||||
if result != nil {
|
||||
t.Fatalf("invalid dynamic identity returned a result: %#v", result.Config)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != path.String() {
|
||||
t.Fatalf("dynamic identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityAppliesToLayeredLoads(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Defaults: func() map[string]any {
|
||||
return map[string]any{"items": finalIdentityRecords("duplicate")}
|
||||
},
|
||||
}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
if result != nil {
|
||||
t.Fatalf("invalid layered final identities returned a result: %#v", result.Config)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||
t.Fatalf("layered identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityUsesTypedNumbersAndIgnoresUnmatchedShapes(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||
configkit.SequenceIdentityAt(configkit.Keys("unmatched"), "id"),
|
||||
},
|
||||
}
|
||||
_, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: definition.SequenceIdentities,
|
||||
Defaults: func() map[string]any {
|
||||
return map[string]any{"items": []any{
|
||||
map[string]any{"id": int64(1)}, map[string]any{"id": float64(1)},
|
||||
}}
|
||||
},
|
||||
},
|
||||
})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||
t.Fatalf("typed numeric identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
valid, err := configkit.Load(configkit.Mapping("valid", map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"id": nil}, map[string]any{"id": true},
|
||||
map[string]any{"id": int64(1)}, map[string]any{"id": "one"},
|
||||
},
|
||||
"other": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||
}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || valid == nil {
|
||||
t.Fatalf("valid scalar identities and unmatched duplicate sequence = %#v, %v", valid, err)
|
||||
}
|
||||
if _, err := configkit.Load(configkit.Mapping("other-shapes", map[string]any{"items": nil}), configkit.Options[map[string]any]{Definition: definition}); err != nil {
|
||||
t.Fatalf("null at a registered path was validated as an identity sequence: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityDiagnosticDoesNotRevealResolvedSecrets(t *testing.T) {
|
||||
const canary = "FINAL-IDENTITY-CREDENTIAL-CANARY"
|
||||
const secretName = "CONFIGKIT_FINAL_IDENTITY_SECRET"
|
||||
t.Setenv(secretName, canary)
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Token configkit.Secret `yaml:"token"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
definition := configkit.Definition[config]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Defaults: func() config {
|
||||
return config{Items: []item{
|
||||
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||
}}
|
||||
},
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[config]{Definition: definition})
|
||||
if result != nil {
|
||||
t.Fatalf("duplicate identity returned a result: %#v", result.Config)
|
||||
}
|
||||
if err == nil || strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("identity diagnostic leaked a resolved secret: %v", err)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||
t.Fatalf("secret-bearing identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func finalIdentityRecords(shape string) []any {
|
||||
switch shape {
|
||||
case "duplicate":
|
||||
return []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}}
|
||||
case "missing":
|
||||
return []any{map[string]any{"other": "value"}}
|
||||
default:
|
||||
return []any{map[string]any{"id": map[string]any{"nested": "value"}}}
|
||||
}
|
||||
}
|
||||
99
facade_import_alias_test.go
Normal file
99
facade_import_alias_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, contents := range map[string]string{
|
||||
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
|
||||
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
||||
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
||||
"data/resource.txt": "target resource",
|
||||
"aliases/resource.txt": "alias resource",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, target := range map[string]string{"aliases/item.yml": "../data/item.yml", "linked": "data"} {
|
||||
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("aliases", os.DirFS(root))
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
Owner string `yaml:"owner"`
|
||||
Resource string `yaml:"resource"`
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, declaration, item, helper, owner, resource string
|
||||
}{
|
||||
{"file/path", "path: aliases/item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"file/dir", "dir: aliases\n pattern: item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"file/glob", "glob: 'aliases/ite*.yml'", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"directory/path", "path: linked/item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
{"directory/dir", "dir: linked\n pattern: item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
{"directory/glob", "glob: 'lin*/ite*.yml'", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Owner != test.owner || result.Config.Value != "item" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[1].Path != test.item || sources[2].Path != test.helper {
|
||||
t.Fatalf("source identities = %#v", sources)
|
||||
}
|
||||
for field, source := range map[string]configkit.SourceInfo{"value": sources[1], "resource": sources[2]} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != source.ID {
|
||||
t.Fatalf("%s origin = %#v", field, info)
|
||||
}
|
||||
context, ok := result.SourceContext(source.ID)
|
||||
if !ok || context.BaseDir != path.Dir(source.Path) {
|
||||
t.Fatalf("%s context = %#v", field, context)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Resource))
|
||||
if err != nil || string(contents) != test.resource {
|
||||
t.Fatalf("%s resource = %q, %v", field, contents, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// The same native path is still a repeat when separately included. An alias
|
||||
// to a leaf file remains a different native namespace path, as for path imports.
|
||||
for _, selector := range []string{"path: aliases/item.yml", "dir: aliases\n pattern: item.yml", "glob: 'aliases/ite*.yml'"} {
|
||||
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
||||
t.Fatalf("repeated source: %v", err)
|
||||
}
|
||||
_, err = configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("distinct native alias paths should reach composition: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
260
facade_import_defaults_test.go
Normal file
260
facade_import_defaults_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
depth string
|
||||
valid bool
|
||||
}{
|
||||
{"999999999999999999999999.5", false},
|
||||
{"999999999999999999999999e1", false},
|
||||
{"999999999999999999999999", true},
|
||||
{"-0", true},
|
||||
} {
|
||||
t.Run(test.depth, func(t *testing.T) {
|
||||
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfigkit:\n imports:\n - dir: absent\n recursive: true\n depth: "+test.depth+"\n")
|
||||
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
|
||||
if test.valid {
|
||||
if err != nil || result.Config["value"] != true {
|
||||
t.Fatalf("valid depth: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("invalid depth accepted: %v", err)
|
||||
}
|
||||
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
|
||||
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
|
||||
type config struct {
|
||||
Main string `yaml:"main"`
|
||||
Transitive string `yaml:"transitive"`
|
||||
Exception string `yaml:"exception"`
|
||||
Underlay string `yaml:"underlay"`
|
||||
IgnoredDeep string `yaml:"ignored_deep"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent/parent.yml
|
||||
main: main
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
|
||||
imports:
|
||||
- dir: generated
|
||||
- dir: explicit
|
||||
pattern: '*.yaml'
|
||||
recursive: false
|
||||
`)
|
||||
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
|
||||
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
|
||||
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
|
||||
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
|
||||
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
||||
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
|
||||
imports:
|
||||
- dir: underlay-files
|
||||
`)
|
||||
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
|
||||
recursive := true
|
||||
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
|
||||
t.Fatalf("configuration = %#v", loaded.Config)
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, source := range loaded.Sources() {
|
||||
paths[source.Path] = true
|
||||
}
|
||||
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
|
||||
if !paths[canonicalFacadeImportPath(t, selected)] {
|
||||
t.Errorf("selected source %q missing from source records", selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: snippets
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
||||
|
||||
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil || unrestricted.Config.Value != "imported" {
|
||||
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
||||
}
|
||||
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
||||
if err != nil || empty.Config.Value != "" {
|
||||
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||
}
|
||||
emptySources := empty.Sources()
|
||||
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone {
|
||||
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||
}
|
||||
required := true
|
||||
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||
}
|
||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||
}
|
||||
|
||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
||||
imports:
|
||||
- dir: recursive-files
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
||||
recursive := true
|
||||
patterns := []string{"*.cfg"}
|
||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
||||
recursive = false
|
||||
patterns[0] = "["
|
||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
for _, layered := range []bool{false, true} {
|
||||
input := strings.NewReader("value: read\n")
|
||||
underlayInput := strings.NewReader("value: underlay\n")
|
||||
defaultsCalled := false
|
||||
resolveCalled := false
|
||||
definition := Definition[config]{
|
||||
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
|
||||
}
|
||||
defaults := ImportDefaults{Pattern: []string{"["}}
|
||||
var err error
|
||||
if layered {
|
||||
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
|
||||
Definition: definition, ImportDefaults: defaults,
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
|
||||
})
|
||||
} else {
|
||||
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
if loadError != nil {
|
||||
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("layered=%v error = %T %v", layered, err, err)
|
||||
}
|
||||
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
|
||||
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
value: loaded
|
||||
`)
|
||||
callbackPath := ""
|
||||
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
first, ok := resolution.Source("source-1")
|
||||
if !ok || len(first.ImportSelections) != 1 {
|
||||
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||
}
|
||||
first.ImportSelections[0].Path = Keys("callback mutation")
|
||||
second, ok := resolution.Source("source-1")
|
||||
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||
}
|
||||
callbackPath = second.ImportSelections[0].Path.String()
|
||||
return nil
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackPath != "/configkit/imports/0" {
|
||||
t.Fatalf("callback path = %q", callbackPath)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory {
|
||||
t.Fatalf("result source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].ImportSelections[0].Path = Keys("result mutation")
|
||||
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||
}
|
||||
|
||||
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
- path: required-missing.yml
|
||||
required: true
|
||||
`)
|
||||
_, err = Load(File(failurePath), Options[config]{Imports: true})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
errorSources := loadError.Sources()
|
||||
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("error source evidence = %#v", errorSources)
|
||||
}
|
||||
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
|
||||
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func canonicalFacadeImportPath(t *testing.T, filename string) string {
|
||||
t.Helper()
|
||||
canonical, err := filepath.EvalSymlinks(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.ToSlash(canonical)
|
||||
}
|
||||
135
facade_import_lifecycle_test.go
Normal file
135
facade_import_lifecycle_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
||||
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"bundle/config/main.yml": {Data: []byte(`configkit:
|
||||
imports:
|
||||
- dir: records
|
||||
pattern: '*-item.yml'
|
||||
destination: [services, records]
|
||||
placement: sequence-item
|
||||
services:
|
||||
records:
|
||||
- id: authored
|
||||
version: authored
|
||||
`)},
|
||||
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
|
||||
imports:
|
||||
- path: helper.yml
|
||||
version: ` + canary + `
|
||||
entries:
|
||||
parent:
|
||||
resource: icon.txt
|
||||
child:
|
||||
extends: parent
|
||||
`)},
|
||||
"bundle/config/records/helper.yml": {Data: []byte("id: helper-supplied\n")},
|
||||
"bundle/config/records/icon.txt": {Data: []byte("icon payload")},
|
||||
})
|
||||
result, err := Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||
Secret: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
SourceVersionMode: SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil {
|
||||
var loadError *LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load discovered item: %v; diagnostics: %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("load discovered item: %v", err)
|
||||
}
|
||||
services := result.Config["services"].(map[string]any)
|
||||
records := services["records"].([]any)
|
||||
if len(records) != 2 || records[0].(map[string]any)["id"] != "helper-supplied" || records[0].(map[string]any)["version"] != nil || records[1].(map[string]any)["version"] != "authored" {
|
||||
t.Fatalf("nested records = %#v", records)
|
||||
}
|
||||
childPath := Keys("services", "records").Index(0).Key("entries").Key("child").Key("resource")
|
||||
child := records[0].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||
if child["resource"] != "icon.txt" {
|
||||
t.Fatalf("inherited relative resource = %#v", child)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
filesByID := make(map[string]string, len(sources))
|
||||
for _, source := range sources {
|
||||
filesByID[source.ID] = source.Path
|
||||
}
|
||||
var itemSource SourceInfo
|
||||
for _, source := range sources {
|
||||
if source.Path == "bundle/config/records/a-item.yml" {
|
||||
itemSource = source
|
||||
}
|
||||
}
|
||||
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
|
||||
}
|
||||
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
||||
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||
t.Fatalf("assigned helper ID origin = %#v", idInfo)
|
||||
}
|
||||
resourceInfo, ok := result.Metadata().Lookup(childPath)
|
||||
if !ok || len(resourceInfo.Origin.Locations) != 1 || filesByID[resourceInfo.Origin.Locations[0].SourceID] != "bundle/config/records/a-item.yml" {
|
||||
t.Fatalf("inherited resource metadata = %#v", resourceInfo)
|
||||
}
|
||||
explanation, ok := result.Explain(childPath)
|
||||
if !ok || explanation.Effective.YAML != "icon.txt\n" || len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != resourceInfo.Origin.Locations[0].SourceID {
|
||||
t.Fatalf("inherited resource explanation = %#v", explanation)
|
||||
}
|
||||
context, ok := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||
if !ok || context.FS == nil || context.BaseDir != "config/records" {
|
||||
t.Fatalf("inherited resource context = %#v, %v", context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child["resource"].(string)))
|
||||
if err != nil || string(contents) != "icon payload" {
|
||||
t.Fatalf("inherited resource read = %q, %v", contents, err)
|
||||
}
|
||||
|
||||
_, err = Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||
Secret: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
SourceVersionMode: SourceVersionModeIfPresent,
|
||||
})
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid {
|
||||
t.Fatalf("consumed header supplied an identity: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("diagnostic exposed the consumed secret header: %v", err)
|
||||
}
|
||||
var failedItemSource SourceInfo
|
||||
foundFailedItem := false
|
||||
for _, source := range loadError.Sources() {
|
||||
if source.Path == "bundle/config/records/a-item.yml" {
|
||||
failedItemSource = source
|
||||
foundFailedItem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
|
||||
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||
}
|
||||
}
|
||||
119
facade_import_root_test.go
Normal file
119
facade_import_root_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := filepath.Join(root, "real", "parts")
|
||||
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partPath := filepath.Join(parts, "part.yml")
|
||||
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
for _, follow := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
||||
mainPath := filepath.Join(root, "main.yml")
|
||||
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
||||
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config["value"] != "included" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
|
||||
t.Fatalf("canonical imported source = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parts := filepath.Join(root, "nested/root/parts")
|
||||
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(parts, "value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("parts", filepath.Join(root, "nested/root/linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("nested", os.DirFS(root))
|
||||
context := &configkit.FilesystemContext{FS: &filesystem, Root: "nested/root", BaseDir: "nested/root"}
|
||||
for _, selector := range []string{
|
||||
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
||||
} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config["value"] != int64(1) {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "nested/outside.yml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../../outside.yml", filepath.Join(parts, "escape.yml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
|
||||
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
||||
t.Fatalf("escaping %s: %v", selector, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
|
||||
for _, native := range []bool{false, true} {
|
||||
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
|
||||
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||
if native {
|
||||
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
if err != nil {
|
||||
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||
}
|
||||
if result.Config["value"] != int64(1) {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,214 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testing.T) {
|
||||
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET"
|
||||
const canary = "IDENTIFIED-INHERITANCE-SECRET-CANARY"
|
||||
t.Setenv(secretName, canary)
|
||||
type step struct {
|
||||
ID string `yaml:"id"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type job struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
Resource string `yaml:"resource"`
|
||||
Steps []step `yaml:"steps"`
|
||||
Settings map[string]map[string]string `yaml:"settings"`
|
||||
}
|
||||
type configuration struct {
|
||||
Jobs []job `yaml:"jobs"`
|
||||
}
|
||||
input := []byte(`jobs:
|
||||
- id: child
|
||||
extends: {id: parent}
|
||||
name: child-name
|
||||
- id: parent
|
||||
extends: {id: base}
|
||||
- id: base
|
||||
name: base-name
|
||||
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET
|
||||
resource: icon.txt
|
||||
steps:
|
||||
- id: base-step
|
||||
value: base-step-value
|
||||
- id: second-step
|
||||
value: second-step-value
|
||||
settings:
|
||||
primary: {value: base-setting}
|
||||
secondary: {extends: primary}
|
||||
`)
|
||||
filesystem := configkit.NewFilesystem("identified-bundle", fstest.MapFS{
|
||||
"config/identified.yml": &fstest.MapFile{Data: input},
|
||||
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
|
||||
})
|
||||
registrations := []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
|
||||
}
|
||||
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: registrations,
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("load identified inheritance: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Jobs) != 3 || result.Config.Jobs[0].ID != "child" || result.Config.Jobs[1].ID != "parent" || result.Config.Jobs[2].ID != "base" {
|
||||
t.Fatalf("inherited job positions and identities = %#v", result.Config.Jobs)
|
||||
}
|
||||
child := result.Config.Jobs[0]
|
||||
if child.Name != "child-name" || child.Credential.Value() != canary || child.Resource != "icon.txt" || len(child.Steps) != 2 {
|
||||
t.Fatalf("inherited child = %#v", child)
|
||||
}
|
||||
if got := []string{child.Steps[0].ID, child.Steps[1].ID}; strings.Join(got, ",") != "base-step,second-step" {
|
||||
t.Fatalf("inherited nested steps = %v", got)
|
||||
}
|
||||
if child.Steps[0].Value != "base-step-value" || child.Steps[1].Value != "second-step-value" || child.Settings["secondary"]["value"] != "base-setting" {
|
||||
t.Fatalf("inherited nested collection and mapping = %#v", child)
|
||||
}
|
||||
credentialPath := configkit.Keys("jobs").Index(0).Key("credential")
|
||||
credentialInfo, found := result.Metadata().Lookup(credentialPath)
|
||||
if !found || len(credentialInfo.Origin.Inheritance) != 2 {
|
||||
t.Fatalf("inherited secret provenance = %#v, %v", credentialInfo, found)
|
||||
}
|
||||
resourceInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||
if !found || len(resourceInfo.Origin.Inheritance) != 2 || len(resourceInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("inherited resource provenance = %#v, %v", resourceInfo, found)
|
||||
}
|
||||
context, found := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||
if !found || context.BaseDir != "config" || context.FS == nil {
|
||||
t.Fatalf("inherited resource context = %#v, %v", context, found)
|
||||
}
|
||||
resource, readErr := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
|
||||
if readErr != nil || string(resource) != "identified-parent-resource" {
|
||||
t.Fatalf("inherited resource = %q, %v", resource, readErr)
|
||||
}
|
||||
resourceHistory, found := result.Explain(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||
if !found || len(resourceHistory.History) == 0 {
|
||||
t.Fatalf("inherited resource history = %#v, %v", resourceHistory, found)
|
||||
}
|
||||
credentialHistory, found := result.Explain(credentialPath)
|
||||
if !found || !credentialHistory.Effective.Redacted {
|
||||
t.Fatalf("inherited secret explanation = %#v, %v", credentialHistory, found)
|
||||
}
|
||||
identityInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("id"))
|
||||
if !found || !identityInfo.Explicit || len(identityInfo.Origin.Inheritance) != 0 ||
|
||||
len(identityInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("child identity origin and presence = %#v, %v", identityInfo, found)
|
||||
}
|
||||
if _, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("extends")); found {
|
||||
t.Fatal("identified record relationship remained in effective metadata")
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || strings.Contains(rendered.String(), "extends:") {
|
||||
t.Fatalf("effective inherited configuration = %q, %v", rendered.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.T) {
|
||||
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET"
|
||||
const canary = "IDENTIFIED-INHERITANCE-FAILURE-CANARY"
|
||||
t.Setenv(secretName, canary)
|
||||
type job struct {
|
||||
ID string `yaml:"id"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
type configuration struct {
|
||||
Jobs []job `yaml:"jobs"`
|
||||
}
|
||||
input := []byte(`jobs:
|
||||
- id: child
|
||||
extends: {id: missing}
|
||||
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET
|
||||
`)
|
||||
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||
},
|
||||
})
|
||||
if err == nil || strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("identified inheritance failure = %v; secret canary must remain undisclosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
|
||||
policy := func(mode string) string {
|
||||
return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
lower string
|
||||
higher string
|
||||
wantCode configkit.Code
|
||||
wantValue string
|
||||
wantSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "upserted parent supplies its final payload",
|
||||
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||
higher: policy("upsert") + "jobs: [{id: parent, value: higher}, {id: child, extends: {id: parent}}]\n",
|
||||
wantValue: "higher", wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "replacement removes a lower parent before lookup",
|
||||
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||
higher: policy("replace") + "jobs: [{id: child, extends: {id: parent}}]\n",
|
||||
wantCode: configkit.CodeInheritanceMissing,
|
||||
},
|
||||
{
|
||||
name: "upserted child without a directive replaces the invalid lower child",
|
||||
lower: policy("upsert") + "jobs: [{id: child, extends: {id: missing}, value: lower}]\n",
|
||||
higher: policy("upsert") + "jobs: [{id: child, value: higher}]\n",
|
||||
wantValue: "higher", wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "self-reference cannot resolve through a lower predecessor",
|
||||
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||
higher: policy("upsert") + "jobs: [{id: parent, extends: {id: parent}, value: higher}]\n",
|
||||
wantCode: configkit.CodeInheritanceCycle,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main.yml", []byte(test.higher))}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower.yml", []byte(test.lower))}}}},
|
||||
})
|
||||
if test.wantSuccess {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs, ok := result.Config["jobs"].([]any)
|
||||
if !ok || len(jobs) == 0 {
|
||||
t.Fatalf("assembled jobs = %#v", result.Config["jobs"])
|
||||
}
|
||||
child, ok := jobs[len(jobs)-1].(map[string]any)
|
||||
if !ok || child["value"] != test.wantValue {
|
||||
t.Fatalf("surviving record = %#v, want inherited value %q", jobs[len(jobs)-1], test.wantValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("load error = %v, want code %s", err, test.wantCode)
|
||||
}
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if diagnostic.Code == test.wantCode {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("load diagnostics = %#v, want code %s", loadError.Diagnostics(), test.wantCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
||||
@@ -206,27 +414,24 @@ func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
|
||||
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
||||
})
|
||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
||||
})
|
||||
if err != nil || result.Config.Entries["child"].Count != 2 {
|
||||
t.Fatalf("import and CLI reference = %#v, %v", result, err)
|
||||
}
|
||||
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
||||
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||
t.Fatalf("superseded reference history = %#v", history)
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOverrideDirective {
|
||||
t.Fatalf("CLI inheritance relationship change = %v", err)
|
||||
}
|
||||
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
|
||||
})
|
||||
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
loadError, ok = err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("strict conflict before expansion = %v", err)
|
||||
}
|
||||
|
||||
69
facade_item_registration_scope_test.go
Normal file
69
facade_item_registration_scope_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, layered := range []bool{false, true} {
|
||||
for _, dynamic := range []bool{false, true} {
|
||||
for _, missingOuterID := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("layered=%t/dynamic=%t/missing-outer-id=%t", layered, dynamic, missingOuterID), func(t *testing.T) {
|
||||
payload := "prompts: [{text: helper}]\n"
|
||||
unused := configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")
|
||||
if dynamic {
|
||||
payload = "catalog: {local: {prompts: [{text: helper}]}}\n"
|
||||
unused = configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id")
|
||||
}
|
||||
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
||||
if !missingOuterID {
|
||||
item += "id: outer\n"
|
||||
}
|
||||
files := configkit.NewFilesystem("items", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||
"child-helper.yml": {Data: []byte(payload)},
|
||||
})
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
unused, configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||
}}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if layered {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: files.File("main.yml"), Imports: true}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
} else {
|
||||
result, err = configkit.Load(files.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||
}
|
||||
if missingOuterID {
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeComposeInvalid || failure.Diagnostics()[0].Path.String() != "/items/0/id" {
|
||||
t.Fatalf("outer constraint diagnostics = %#v", failure.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range []configkit.Path{configkit.Keys("items").Index(0), configkit.Keys("items").Index(0).Key("children").Index(0)} {
|
||||
path := record
|
||||
if dynamic {
|
||||
path = path.Key("catalog").Key("local")
|
||||
}
|
||||
info, found := result.Metadata().Lookup(path.Key("prompts").Index(0).Key("text"))
|
||||
if !found || !info.Explicit {
|
||||
t.Fatalf("nested helper payload lost at %s", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
facade_item_version_test.go
Normal file
64
facade_item_version_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, item, child, helper string
|
||||
underlay, mainItem bool
|
||||
}{
|
||||
{name: "item", item: "id: item\nversion: domain\n"},
|
||||
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
|
||||
{name: "nested", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n"},
|
||||
{name: "underlay", item: "id: item\nversion: domain\n", underlay: true},
|
||||
{name: "main-item", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n", mainItem: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
"item.yml": {Data: []byte(test.item)},
|
||||
"child.yml": {Data: []byte(test.child)},
|
||||
"helper.yml": {Data: []byte(test.helper)},
|
||||
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("items", files)
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
switch {
|
||||
case test.underlay:
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"version": "1"})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: filesystem.File("underlay.yml"), Imports: true}}}},
|
||||
})
|
||||
case test.mainItem:
|
||||
result, err = configkit.Load(filesystem.File("item.yml"), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, Definition: definition,
|
||||
})
|
||||
default:
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := result.Config["items"].([]any)[0].(map[string]any)
|
||||
fieldPath := configkit.Keys("items").Index(0).Key("version")
|
||||
if test.child != "" {
|
||||
item = item["children"].([]any)[0].(map[string]any)
|
||||
fieldPath = configkit.Keys("items").Index(0).Key("children").Index(0).Key("version")
|
||||
}
|
||||
if item["version"] != "domain" {
|
||||
t.Fatalf("domain version = %#v", item)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(fieldPath); !ok || !info.Explicit {
|
||||
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
240
facade_sequence_identity_test.go
Normal file
240
facade_sequence_identity_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {
|
||||
fields := []string{"namespace", "id", "id"}
|
||||
selector := configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}
|
||||
identity := configkit.SequenceIdentityMatching(selector, fields...)
|
||||
selector[1] = configkit.SequenceLiteralKey("other")
|
||||
fields[0] = "changed"
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}}
|
||||
|
||||
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}},
|
||||
}})
|
||||
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}},
|
||||
}})
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load after mutating constructor inputs: %v", err)
|
||||
}
|
||||
prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||
if len(prompts) != 2 {
|
||||
t.Fatalf("registered compound identity did not append records: %#v", prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) {
|
||||
dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id")
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"),
|
||||
dynamic,
|
||||
}}
|
||||
underlay := configkit.Mapping("underlay", map[string]any{
|
||||
"prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}},
|
||||
"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}},
|
||||
},
|
||||
})
|
||||
main := configkit.Mapping("main", map[string]any{
|
||||
"prompts": []any{map[string]any{"namespace": "beta", "id": "same"}},
|
||||
"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}},
|
||||
},
|
||||
})
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load mixed exact and dynamic registrations: %v", err)
|
||||
}
|
||||
rootRecords := loaded.Config["prompts"].([]any)
|
||||
if len(rootRecords) != 2 {
|
||||
t.Fatalf("compound root identities = %#v", rootRecords)
|
||||
}
|
||||
collections := loaded.Config["collections"].(map[string]any)
|
||||
for _, name := range []string{"team", "cloud"} {
|
||||
prompts := collections[name].(map[string]any)["prompts"].([]any)
|
||||
if len(prompts) != 2 {
|
||||
t.Errorf("dynamic prompts for %q = %#v", name, prompts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceLiteralKey("*"),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
}}
|
||||
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||
"*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}},
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||
}})
|
||||
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||
"*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}},
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||
}})
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||
})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||
t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics())
|
||||
}
|
||||
|
||||
wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
}}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: wildcard,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wildcard registration did not match mapping keys: %v", err)
|
||||
}
|
||||
for name, raw := range loaded.Config["collections"].(map[string]any) {
|
||||
if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 {
|
||||
t.Errorf("wildcard result for %q has %d records", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity configkit.SequenceIdentity
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "index path",
|
||||
identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"),
|
||||
wantPath: "/records/0",
|
||||
},
|
||||
{
|
||||
name: "empty fields",
|
||||
identity: configkit.SequenceIdentityAt(configkit.Keys("records")),
|
||||
wantPath: "/records",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := strings.NewReader("records: []\n")
|
||||
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}},
|
||||
})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath {
|
||||
t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
if reader.Len() == 0 {
|
||||
t.Fatal("source was read before registration validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) {
|
||||
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()}
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(selector, "id"),
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"),
|
||||
}, "slug"),
|
||||
}}
|
||||
reader := strings.NewReader("collections: {}\n")
|
||||
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
if reader.Len() == 0 {
|
||||
t.Fatal("source was read before overlapping registrations were rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("entries"),
|
||||
configkit.SequenceLiteralKey("child"),
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
}}
|
||||
loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs:
|
||||
entries:
|
||||
parent:
|
||||
collections:
|
||||
team:
|
||||
prompts: [{id: inherited, text: base}]
|
||||
child:
|
||||
extends: parent
|
||||
collections:
|
||||
team:
|
||||
prompts: [{id: added, text: child}]
|
||||
`)), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatalf("load with dynamic inherited destination: %v", err)
|
||||
}
|
||||
child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any)
|
||||
prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||
if len(prompts) != 2 {
|
||||
t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(),
|
||||
}, "id"),
|
||||
}}
|
||||
for _, source := range []string{"first", "second"} {
|
||||
loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{
|
||||
source: []any{map[string]any{"id": "reused"}},
|
||||
}}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 {
|
||||
t.Fatalf("independent load %q = %#v, %v", source, loaded, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError {
|
||||
t.Helper()
|
||||
var loadErr *configkit.LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("error = %v, want *LoadError", err)
|
||||
}
|
||||
return loadErr
|
||||
}
|
||||
699
facade_sequence_test.go
Normal file
699
facade_sequence_test.go
Normal file
@@ -0,0 +1,699 @@
|
||||
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.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}
|
||||
}
|
||||
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(ordinary.Config, layered.Config) {
|
||||
t.Fatal("NoSource values differ between entry points")
|
||||
}
|
||||
for _, result := range []*configkit.Result[map[string]any]{ordinary, layered} {
|
||||
if len(result.Sources()) != 0 || result.Metadata().IsExplicit(configkit.Keys()) {
|
||||
t.Fatal("NoSource contributed an explicit root")
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys())
|
||||
if !ok || info.Origin.Layer != configkit.LayerDefault {
|
||||
t.Fatalf("NoSource root origin = %#v, %t", info, ok)
|
||||
}
|
||||
}
|
||||
empty, err := configkit.Load(configkit.Mapping("empty", map[string]any{}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || !empty.Metadata().IsExplicit(configkit.Keys()) {
|
||||
t.Fatal("real empty mapping lost its presence")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"one.yml": {Data: []byte("id: one\nconfigkit: {imports: [{path: helper-one.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n")},
|
||||
"two.yml": {Data: []byte("id: two\nconfigkit: {imports: [{path: helper-two.yml}]}\n")},
|
||||
"helper-one.yml": {Data: []byte("text: first helper\n")},
|
||||
"helper-two.yml": {Data: []byte("text: second helper\n")},
|
||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
||||
})
|
||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceFiles := make(map[string]string)
|
||||
for _, source := range result.Sources() {
|
||||
sourceFiles[source.ID] = source.Path
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path configkit.Path
|
||||
file, text string
|
||||
}{
|
||||
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||
} {
|
||||
explanation, ok := result.Explain(test.path)
|
||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||
t.Fatalf("helper history at %s = %#v", test.path.String(), explanation)
|
||||
}
|
||||
locations := explanation.History[0].Origin.Locations
|
||||
if len(locations) != 1 || sourceFiles[locations[0].SourceID] != test.file {
|
||||
t.Fatalf("helper origin at %s = %#v", test.path.String(), locations)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(test.path)
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != locations[0].SourceID {
|
||||
t.Fatal("metadata and explanation disagree")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
upper []any
|
||||
path configkit.Path
|
||||
}{
|
||||
{"shorter", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(1).Key("id")},
|
||||
{"empty", []any{}, configkit.Keys("records").Index(0).Key("id")},
|
||||
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mainYAML := "configkit:\n sequences:\n - destination: [records]\n key: id\n mode: replace\n"
|
||||
if len(test.upper) == 0 {
|
||||
mainYAML += "records: []\n"
|
||||
} else {
|
||||
mainYAML += "records: [{id: main}]\n"
|
||||
}
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "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]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}
|
||||
for _, test := range []struct {
|
||||
name, file, path string
|
||||
grouped, itemMain bool
|
||||
}{
|
||||
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var err error
|
||||
input := configkit.Input{Source: filesystem.File(test.file), Imports: true}
|
||||
if test.itemMain {
|
||||
input.Destination = configkit.Keys("records")
|
||||
input.PlacementMode = configkit.PlacementModeSequenceItem
|
||||
}
|
||||
if test.grouped {
|
||||
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Mapping("prior", map[string]any{"records": []any{map[string]any{"id": "prior"}}})},
|
||||
input,
|
||||
}}},
|
||||
})
|
||||
} else {
|
||||
_, err = configkit.Load(input.Source, configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: input.Destination, PlacementMode: input.PlacementMode})
|
||||
}
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("expected helper conflict: %v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != test.path {
|
||||
t.Fatalf("diagnostic = %s %s, want %s", diagnostic.Code, diagnostic.Path.String(), test.path)
|
||||
}
|
||||
files := make(map[string]string)
|
||||
for _, source := range loadError.Sources() {
|
||||
files[source.ID] = source.Path
|
||||
}
|
||||
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||
if diagnostic.Primary != nil {
|
||||
locations = append(locations, *diagnostic.Primary)
|
||||
}
|
||||
gotFiles := make(map[string]bool, len(locations))
|
||||
for _, location := range locations {
|
||||
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
|
||||
}
|
||||
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
|
||||
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "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) != 2 || got[0].(map[string]any)["id"] != "lower" || got[1].(map[string]any)["id"] != "main" {
|
||||
t.Fatalf("registered upsert did not retain 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]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
for _, id := range []string{"source-2", "source-3"} {
|
||||
source, ok := resolution.Source(id)
|
||||
if !ok {
|
||||
t.Errorf("resolver could not query %s", id)
|
||||
continue
|
||||
}
|
||||
context, found := resolution.SourceContext(id)
|
||||
if !found || context.FS == nil || context.BaseDir != "records" {
|
||||
t.Errorf("resolver context for %s = %#v, %v", id, context, found)
|
||||
}
|
||||
callbackSources = append(callbackSources, source)
|
||||
callbackContexts = append(callbackContexts, context)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load item glob: %v", err)
|
||||
}
|
||||
items := result.Config["items"].([]any)
|
||||
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||
for index, item := range items {
|
||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||
}
|
||||
}
|
||||
if len(callbackSources) != 2 || len(callbackContexts) != 2 {
|
||||
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
||||
}
|
||||
for index, source := range callbackSources {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
|
||||
t.Errorf("callback source = %#v", source)
|
||||
}
|
||||
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
||||
t.Errorf("callback context = %#v", callbackContexts[index])
|
||||
}
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
for index := 0; index < 2; index++ {
|
||||
path := configkit.Keys("items").Index(index).Key("id")
|
||||
info, ok := metadata.Lookup(path)
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
|
||||
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||
}
|
||||
}
|
||||
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
|
||||
t.Fatalf("final item sources = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
helperPath := filepath.Join(directory, "helper.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: main\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("early failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
||||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
|
||||
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("early source snapshots = %#v", sources)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(helperPath, []byte("value: invalid-for-typed-schema\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Metadata struct {
|
||||
Value int `yaml:"value"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
type configuration struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[configuration]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok = err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("post-placement failure = %v", err)
|
||||
}
|
||||
sources = loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
|
||||
t.Fatalf("post-placement source snapshots = %#v", sources)
|
||||
}
|
||||
firstSnapshot := sources[1]
|
||||
sources[1].Destination = configkit.Keys("mutated")
|
||||
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
|
||||
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
||||
}
|
||||
|
||||
var callback configkit.SourceInfo
|
||||
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
callback, _ = resolution.Source("source-2")
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("successful item load: %v", err)
|
||||
}
|
||||
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("callback source snapshot = %#v", callback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
childPath := filepath.Join(directory, "child.yml")
|
||||
helperPath := filepath.Join(directory, "helper.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(childPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: child\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("groups"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("nested acquisition failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("nested source snapshots = %#v", sources)
|
||||
}
|
||||
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("outer item snapshot = %#v", got)
|
||||
}
|
||||
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("nested item fallback = %#v", got)
|
||||
}
|
||||
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Errorf("nested helper fallback = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
|
||||
fields := []string{"id"}
|
||||
path := configkit.Keys("records")
|
||||
registration := configkit.SequenceIdentityAt(path, fields...)
|
||||
fields[0] = "changed"
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{registration},
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(configkit.Version, *configkit.Validation) error {
|
||||
path = configkit.Keys("other")
|
||||
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 TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||
}
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("lower", []byte("entries: [{id: lower}]\n")),
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index, id := range []string{"lower", "main"} {
|
||||
path := configkit.Keys("entries").Index(index).Key("id")
|
||||
explanation, ok := result.Explain(path)
|
||||
if !ok || !explanation.Effective.Present || explanation.Effective.YAML != id+"\n" || len(explanation.History) == 0 {
|
||||
t.Fatalf("tier history at %s = %#v, %v", path.String(), explanation, ok)
|
||||
}
|
||||
if explanation.History[len(explanation.History)-1].After.YAML != id+"\n" {
|
||||
t.Fatalf("tier history at %s does not retain final record: %#v", path.String(), explanation.History)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(`configkit:
|
||||
sequences:
|
||||
- destination: [entries]
|
||||
key: id
|
||||
mode: replace
|
||||
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]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
facade_sequence_version_test.go
Normal file
197
facade_sequence_version_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type movedVersionItem struct {
|
||||
ID string `yaml:"id"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
|
||||
type movedVersionCatalog struct {
|
||||
Items []movedVersionItem `yaml:"items"`
|
||||
}
|
||||
|
||||
func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mounted := range []bool{false, true} {
|
||||
for _, matching := range []bool{false, true} {
|
||||
for _, version := range []string{"4", "'4'"} {
|
||||
t.Run(fmt.Sprintf("mounted=%t/matched=%t/%s", mounted, matching, version), func(t *testing.T) {
|
||||
body := "items: [{id: main, version: " + version + "}]\n"
|
||||
path := configkit.Keys("items").Index(0).Key("version")
|
||||
main := configkit.Input{}
|
||||
if mounted {
|
||||
body, path = "id: main\nversion: "+version+"\n", configkit.Keys("version")
|
||||
main.Destination, main.PlacementMode = configkit.Keys("items"), configkit.PlacementModeSequenceItem
|
||||
}
|
||||
main.Source = configkit.Bytes("main", []byte(body))
|
||||
keep := "{id: keep}"
|
||||
if mounted {
|
||||
keep = "{id: keep, version: ordinary}"
|
||||
}
|
||||
lower := "items: [" + keep + "]\n"
|
||||
if matching {
|
||||
lower = "items: [" + keep + ", {id: main}]\n"
|
||||
}
|
||||
calls := 0
|
||||
loaded, err := configkit.LoadLayered(main, configkit.LayeredOptions[movedVersionCatalog]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(lower))}}}},
|
||||
Definition: configkit.Definition[movedVersionCatalog]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Version: &configkit.VersionRule{Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
calls++
|
||||
if !version.Present || version.Text != "4" {
|
||||
t.Errorf("checked version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || len(loaded.Config.Items) != 2 || loaded.Config.Items[1].ID != "main" || loaded.Config.Items[1].Version != "4" {
|
||||
t.Fatalf("moved main = %#v, checker calls = %d", loaded.Config, calls)
|
||||
}
|
||||
if mounted && loaded.Config.Items[0].Version != "ordinary" {
|
||||
t.Fatal("unrelated underlay payload was treated as the main declaration")
|
||||
}
|
||||
for _, source := range loaded.Sources() {
|
||||
if source.Layer == configkit.LayerBase && source.VersionCheck.Path.String() != path.String() {
|
||||
t.Fatal("effective movement changed source-local version evidence")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayVersionProtectionUsesAssignedRecordPositions(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name, payload string
|
||||
code configkit.Code
|
||||
}{
|
||||
{name: "change main header", payload: "[{id: main, version: changed}]", code: configkit.CodeOverlayVersion},
|
||||
{name: "remove main header", payload: "[{id: main}]", code: configkit.CodeOverlayVersion},
|
||||
{name: "other record version", payload: "[{id: keep, version: ordinary}]"},
|
||||
{name: "new record version", payload: "[{id: new, version: ordinary}]"},
|
||||
{name: "only main header gets integer conversion", payload: "[{id: new, version: 5}]", code: configkit.CodeDecodeType},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
files := configkit.NewFilesystem("catalog", fstest.MapFS{
|
||||
"overlays/test.yml": {Data: []byte("items: " + test.payload + "\n")},
|
||||
})
|
||||
calls := 0
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{FS: &files, BaseDir: ".", Root: "."},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte("items: [{id: keep}]\n"))}}}},
|
||||
Definition: configkit.Definition[movedVersionCatalog]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||
},
|
||||
})
|
||||
if calls != 1 {
|
||||
t.Fatalf("checker calls = %d", calls)
|
||||
}
|
||||
if test.code != "" {
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if loaded != nil || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != test.code {
|
||||
t.Fatalf("version diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Config.Items[1].Version != "4" {
|
||||
t.Fatal("unrelated overlay changed main header")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnderlayVersionProtectionUsesMainRecordPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, imported := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("imported=%t", imported), func(t *testing.T) {
|
||||
payload := "items: [{id: keep}, {id: main, version: forbidden}]\n"
|
||||
lower := configkit.Input{Source: configkit.Bytes("lower", []byte(payload))}
|
||||
want := configkit.CodeUnderlayVersion
|
||||
if imported {
|
||||
files := configkit.NewFilesystem("lower", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||
"fragment.yml": {Data: []byte(payload)},
|
||||
})
|
||||
lower.Source, lower.Imports = files.File("main.yml"), true
|
||||
want = configkit.CodeImportVersion
|
||||
}
|
||||
calls := 0
|
||||
result, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")),
|
||||
Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{lower}}},
|
||||
Definition: configkit.Definition[movedVersionCatalog]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||
},
|
||||
})
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || calls != 1 || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != want || failure.Diagnostics()[0].Path.String() != "/items/1/version" {
|
||||
t.Fatalf("result=%#v calls=%d diagnostics=%#v", result, calls, failure.Diagnostics())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainVersionFollowsInheritedSequenceMovement(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, parentID := range []string{"keep", "main"} {
|
||||
t.Run(parentID, func(t *testing.T) {
|
||||
body := "base: {items: [{id: " + parentID + ", version: ordinary}]}\nchild:\n extends: base\n items: [{id: main, version: 4}]\n"
|
||||
type config struct {
|
||||
Base, Child movedVersionCatalog
|
||||
}
|
||||
calls := 0
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte(body)), configkit.Options[config]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[config]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("base", "items"), "id"),
|
||||
configkit.SequenceIdentityAt(configkit.Keys("child", "items"), "id"),
|
||||
},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("child", "items").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||
},
|
||||
})
|
||||
if calls != 1 {
|
||||
t.Fatalf("checker calls = %d", calls)
|
||||
}
|
||||
if parentID == "main" {
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeInheritanceVersion {
|
||||
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Child.Items) != 2 || result.Config.Child.Items[1].Version != "4" || result.Config.Child.Items[0].Version != "ordinary" {
|
||||
t.Fatalf("inherited items = %#v", result.Config.Child.Items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,155 @@ package configkit_test
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, grouped := range []bool{false, true} {
|
||||
index := 0
|
||||
if grouped {
|
||||
index = 1
|
||||
}
|
||||
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} {
|
||||
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||
}
|
||||
first := "records: [{id: first, version: first-version}]\n"
|
||||
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if grouped {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("first", []byte(first))}, {Source: configkit.Bytes("child", []byte(child))},
|
||||
}}},
|
||||
})
|
||||
} else {
|
||||
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)},
|
||||
"child.yml": {Data: []byte(child)},
|
||||
})
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||
Definition: definition, Imports: true, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !grouped {
|
||||
records, ok := result.Config["records"].([]any)
|
||||
if !ok || len(records) != 2 {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
firstRecord, ok := records[0].(map[string]any)
|
||||
if !ok || firstRecord["id"] != "child" {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
check := sources[len(sources)-1].VersionCheck
|
||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||
t.Fatalf("source-local evidence changed: %#v", check)
|
||||
}
|
||||
if sensitivePath != "" {
|
||||
if !check.Redacted || check.Version.Text != "" {
|
||||
t.Fatal("shifted version evidence was disclosed")
|
||||
}
|
||||
} else if check.Redacted || check.Version.Text != "PRIVATE-VERSION-CANARY" {
|
||||
t.Fatal("unclassified version evidence was lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed, err := configkit.ParsePath("/records/0/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, grouped := range []bool{false, true} {
|
||||
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "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)
|
||||
}
|
||||
if len(result.Config["records"].([]any)) != 2 {
|
||||
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
|
||||
@@ -495,7 +636,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
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"
|
||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n required: true\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
106
internal/engine/collection_ancestor_test.go
Normal file
106
internal/engine/collection_ancestor_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestNestedCollectionInitializationIsIndependentOfAncestorPresence(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ancestor := range []string{"{}", "group: {}", "group: null"} {
|
||||
for _, test := range []struct {
|
||||
mode, key, value string
|
||||
valid bool
|
||||
}{
|
||||
{mode: "append", value: "[{id: a}]"},
|
||||
{mode: "upsert", value: "[{id: a}]"},
|
||||
{mode: "append", value: "[]"},
|
||||
{mode: "upsert", value: "[]"},
|
||||
{mode: "append", key: ", key: id", value: "{}"},
|
||||
{mode: "upsert", key: ", key: id", value: "null"},
|
||||
{mode: "append", key: ", key: id", value: "[{id: a}]", valid: true},
|
||||
{mode: "upsert", key: ", key: id", value: "[]", valid: true},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s/%s/%s/%s", ancestor, test.mode, test.key, test.value), func(t *testing.T) {
|
||||
main := "configkit:\n sequences: [{destination: [group, items], mode: " + test.mode + test.key + "}]\ngroup: {items: " + test.value + "}\n"
|
||||
loaded, err := testGroupedLoad(loadRequest[map[string]any]{
|
||||
main: loadInput{source: Bytes("main", []byte(main))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte(ancestor))}}}},
|
||||
})
|
||||
if test.valid {
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("valid initialization failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || loaded != nil {
|
||||
t.Fatalf("invalid collection operation succeeded: %#v, %v", loaded, err)
|
||||
}
|
||||
diagnostics := loadErr.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/group/items" {
|
||||
t.Fatalf("operation diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddedAncestorTransfersNestedRecordPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
type record struct {
|
||||
ID string `yaml:"id"`
|
||||
Steps []map[string]any `yaml:"steps"`
|
||||
}
|
||||
type config struct {
|
||||
Group struct {
|
||||
Items []record `yaml:"items"`
|
||||
} `yaml:"group"`
|
||||
}
|
||||
files := fstest.MapFS{
|
||||
"main.yml": {Data: []byte(`configkit:
|
||||
sequences: [{destination: [group, items], key: id}]
|
||||
imports: [{path: base.yml, destination: [group, items], placement: sequence-item}]
|
||||
group:
|
||||
items: [{id: child, extends: {id: base}, steps: [{name: child}]}]
|
||||
`)},
|
||||
"base.yml": {Data: []byte("configkit:\n sequences: [{destination: [steps], key: name}]\nid: base\nsteps: [{name: base}]\n")},
|
||||
}
|
||||
loaded, err := testGroupedLoad(loadRequest[config]{
|
||||
main: loadInput{source: NewFilesystem("catalog", files).File("main.yml"), imports: true},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("{}"))}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range loaded.Config.Group.Items {
|
||||
if item.ID == "child" {
|
||||
if len(item.Steps) != 2 || item.Steps[0]["name"] != "base" || item.Steps[1]["name"] != "child" {
|
||||
t.Fatalf("record-local identity was lost when adding its ancestor: %#v", item.Steps)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing child record")
|
||||
}
|
||||
|
||||
func TestInheritanceChecksCollectionOperationUnderAddedAncestor(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := testLoad[map[string]any](Bytes("inheritance", []byte(`configkit:
|
||||
sequences: [{destination: [child, group, items], mode: append}]
|
||||
base: {}
|
||||
child:
|
||||
extends: base
|
||||
group: {items: [{id: a}]}
|
||||
`)))
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("missing nested operation error: %v", err)
|
||||
}
|
||||
diagnostics := loadErr.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/child/group/items" {
|
||||
t.Fatalf("nested inheritance operation diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
98
internal/engine/collection_lookup_test.go
Normal file
98
internal/engine/collection_lookup_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) {
|
||||
var baseBody, overlayBody strings.Builder
|
||||
basePolicies := collectionPolicyScope{}
|
||||
var declarations []sequenceDeclarationSpec
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
fmt.Fprintf(&baseBody, "%s: {items: [{id: base}], retained: [{id: retained}], scalar: pending}\n", name)
|
||||
fmt.Fprintf(&overlayBody, "%s: {items: [{id: overlay}], added: true}\n", name)
|
||||
basePolicies.set(Keys(name, "items"), collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
for _, key := range []string{"retained", "missing", "scalar"} {
|
||||
declarations = append(declarations, sequenceDeclarationSpec{
|
||||
destination: Keys(name, key), identityFields: []string{"id"}, identityPresent: true,
|
||||
mode: sequenceModeAppend, modePresent: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
base, diagnostics := composeDocuments(parseCompositionDocument(t, "base", baseBody.String()))
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
base.collectionPolicies = basePolicies
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
overlay := parseCompositionDocument(t, "overlay", overlayBody.String())
|
||||
diagnostics = assembly.applyOverlayWithCollectionPolicy(overlay, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, declarations)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
lookup := buildDocumentLookup(assembly.current.root)
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
items, found := documentAtPathWithLookup(assembly.current.root, Keys(name, "items"), lookup)
|
||||
if !found || items.Kind != document.Sequence || len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "overlay" {
|
||||
t.Fatalf("collection %s did not append in order", name)
|
||||
}
|
||||
for _, key := range []string{"retained", "missing", "scalar"} {
|
||||
policy, found := assembly.current.collectionPolicies.at(Keys(name, key))
|
||||
if !found || !policy.identityPresent || !policy.explicitModePresent || policy.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("retained/pending declaration lost at %s/%s", name, key)
|
||||
}
|
||||
}
|
||||
if _, found := documentAtPathWithLookup(assembly.current.root, Keys(name, "missing"), lookup); found {
|
||||
t.Fatal("pending declaration created a value")
|
||||
}
|
||||
if origin := assembly.current.origins[Keys(name, "items").Index(1).Key("id").String()]; origin.Layer != LayerOverlay {
|
||||
t.Fatal("appended item lost overlay attribution")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritanceBroadCollectionsInOneDefinition(t *testing.T) {
|
||||
var body strings.Builder
|
||||
policies := collectionPolicyScope{}
|
||||
for _, definition := range []string{"base", "child"} {
|
||||
fmt.Fprintf(&body, "%s:\n", definition)
|
||||
if definition == "child" {
|
||||
body.WriteString(" extends: base\n")
|
||||
}
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
fmt.Fprintf(&body, " %s: {items: [{id: %s}]}\n", name, definition)
|
||||
policy := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
if definition == "child" {
|
||||
policy.explicitMode, policy.explicitModePresent = sequenceModeAppend, true
|
||||
}
|
||||
policies.set(Keys(definition, name, "items"), policy)
|
||||
policies.set(Keys(definition, name, "pending"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
}
|
||||
}
|
||||
root := parseCompositionDocument(t, "wide-collections", body.String())
|
||||
result, diagnostics := expandInheritanceWithPolicies(root, nil, policies, defaultProjectionLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
lookup := buildDocumentLookup(result.root)
|
||||
for index := range 64 {
|
||||
name := fmt.Sprintf("group/%03d~", index)
|
||||
items, found := documentAtPathWithLookup(result.root, Keys("child", name, "items"), lookup)
|
||||
if !found || len(items.Sequence) != 2 || items.Sequence[0].Mapping[0].Value.Text != "base" || items.Sequence[1].Mapping[0].Value.Text != "child" {
|
||||
t.Fatalf("inherited collection %s did not append in order", name)
|
||||
}
|
||||
if policy, found := result.collectionPolicies.at(Keys("child", name, "pending")); !found || !policy.identityPresent {
|
||||
t.Fatalf("pending inherited policy lost at %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
209
internal/engine/collection_policy.go
Normal file
209
internal/engine/collection_policy.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package engine
|
||||
|
||||
type collectionPolicy struct {
|
||||
identityFields []string
|
||||
identityPresent bool
|
||||
identityLocation *Location
|
||||
explicitMode sequenceMode
|
||||
explicitModePresent bool
|
||||
modeLocation *Location
|
||||
recordOrder []*collectionRecordOwner
|
||||
recordPolicies map[*collectionRecordOwner]collectionPolicyScope
|
||||
}
|
||||
|
||||
type collectionPolicyResolution struct {
|
||||
policy collectionPolicy
|
||||
operation sequenceMode
|
||||
declarations []sequenceDeclarationSpec
|
||||
declarationsPresent bool
|
||||
identityDeclarationPresent bool
|
||||
modeDeclarationPresent bool
|
||||
applicationIdentityFields []string
|
||||
applicationIdentityPresent bool
|
||||
effectiveIdentityFields []string
|
||||
}
|
||||
|
||||
// resolveCollectionPolicy reconciles declarations for one destination before
|
||||
// peers are composed. The caller supplies only lower policy that is applicable
|
||||
// to this destination; record survival and ownership are resolved elsewhere.
|
||||
func resolveCollectionPolicy(
|
||||
destination Path,
|
||||
declarations []sequenceDeclarationSpec,
|
||||
applicationIdentityFields []string,
|
||||
lower *collectionPolicy,
|
||||
) (collectionPolicyResolution, []Diagnostic) {
|
||||
normalizedDeclarations := cloneSequenceDeclarations(declarations)
|
||||
for index := range normalizedDeclarations {
|
||||
declaration := &normalizedDeclarations[index]
|
||||
if !declaration.identityPresent {
|
||||
continue
|
||||
}
|
||||
fields, err := normalizeSequenceIdentityFields(declaration.identityFields)
|
||||
if err != nil {
|
||||
return collectionPolicyResolution{}, []Diagnostic{{
|
||||
Code: CodeSequenceInvalid, Severity: SeverityError, Path: destination.clone(),
|
||||
Message: "collection identity declaration requires at least one field",
|
||||
Primary: collectionDeclarationLocation(declaration.identityLocation, declaration.location),
|
||||
}}
|
||||
}
|
||||
declaration.identityFields = fields
|
||||
}
|
||||
normalizedApplicationIdentityFields := cloneStrings(applicationIdentityFields)
|
||||
if len(applicationIdentityFields) != 0 {
|
||||
var err error
|
||||
normalizedApplicationIdentityFields, err = normalizeSequenceIdentityFields(applicationIdentityFields)
|
||||
if err != nil {
|
||||
return collectionPolicyResolution{}, []Diagnostic{{
|
||||
Code: CodeOptionsInvalid, Severity: SeverityError, Path: destination.clone(),
|
||||
Message: "application collection identity fields are invalid",
|
||||
}}
|
||||
}
|
||||
}
|
||||
result := collectionPolicyResolution{
|
||||
declarations: normalizedDeclarations,
|
||||
declarationsPresent: len(normalizedDeclarations) != 0,
|
||||
applicationIdentityFields: normalizedApplicationIdentityFields,
|
||||
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
||||
}
|
||||
|
||||
var diagnostics []Diagnostic
|
||||
var declaredIdentityFields []string
|
||||
var declaredIdentityLocation *Location
|
||||
var declaredMode sequenceMode
|
||||
var declaredModeLocation *Location
|
||||
for _, declaration := range normalizedDeclarations {
|
||||
if declaration.identityPresent {
|
||||
result.identityDeclarationPresent = true
|
||||
if declaredIdentityFields == nil {
|
||||
declaredIdentityFields = cloneStrings(declaration.identityFields)
|
||||
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)
|
||||
} else if !equalSequenceIdentityFields(declaredIdentityFields, declaration.identityFields) {
|
||||
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||
destination, declaredIdentityLocation,
|
||||
collectionDeclarationLocation(declaration.identityLocation, declaration.location),
|
||||
"collection identity declarations conflict",
|
||||
))
|
||||
}
|
||||
}
|
||||
if declaration.modePresent {
|
||||
result.modeDeclarationPresent = true
|
||||
if declaredMode == 0 {
|
||||
declaredMode = declaration.mode
|
||||
declaredModeLocation = collectionDeclarationLocation(declaration.modeLocation, declaration.location)
|
||||
} else if declaredMode != declaration.mode {
|
||||
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||
destination, declaredModeLocation,
|
||||
collectionDeclarationLocation(declaration.modeLocation, declaration.location),
|
||||
"collection operation declarations conflict",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
return collectionPolicyResolution{}, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
if lower != nil && lower.identityPresent && declaredIdentityFields != nil && !equalSequenceIdentityFields(lower.identityFields, declaredIdentityFields) {
|
||||
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||
destination, declaredIdentityLocation,
|
||||
collectionDeclarationLocation(lower.identityLocation, Location{}),
|
||||
"collection identity declaration conflicts with retained identity",
|
||||
))
|
||||
}
|
||||
if result.applicationIdentityPresent {
|
||||
if declaredIdentityFields != nil && !equalSequenceIdentityFields(normalizedApplicationIdentityFields, declaredIdentityFields) {
|
||||
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||
destination, declaredIdentityLocation, nil,
|
||||
"collection identity declaration conflicts with application constraint",
|
||||
))
|
||||
}
|
||||
if lower != nil && lower.identityPresent && !equalSequenceIdentityFields(normalizedApplicationIdentityFields, lower.identityFields) {
|
||||
diagnostics = append(diagnostics, collectionPolicyConflictDiagnostic(
|
||||
destination, declaredIdentityLocation,
|
||||
collectionDeclarationLocation(lower.identityLocation, Location{}),
|
||||
"retained collection identity conflicts with application constraint",
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
return collectionPolicyResolution{}, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
if declaredIdentityFields != nil {
|
||||
result.policy.identityFields = declaredIdentityFields
|
||||
result.policy.identityPresent = true
|
||||
result.policy.identityLocation = cloneLocation(declaredIdentityLocation)
|
||||
} else if lower != nil && lower.identityPresent {
|
||||
result.policy.identityFields = cloneStrings(lower.identityFields)
|
||||
result.policy.identityPresent = true
|
||||
result.policy.identityLocation = cloneLocation(lower.identityLocation)
|
||||
}
|
||||
if result.applicationIdentityPresent {
|
||||
result.effectiveIdentityFields = cloneStrings(normalizedApplicationIdentityFields)
|
||||
} else if result.policy.identityPresent {
|
||||
result.effectiveIdentityFields = cloneStrings(result.policy.identityFields)
|
||||
}
|
||||
|
||||
if result.modeDeclarationPresent {
|
||||
result.policy.explicitMode = declaredMode
|
||||
result.policy.explicitModePresent = true
|
||||
result.policy.modeLocation = cloneLocation(declaredModeLocation)
|
||||
} else if lower != nil && lower.explicitModePresent {
|
||||
result.policy.explicitMode = lower.explicitMode
|
||||
result.policy.explicitModePresent = true
|
||||
result.policy.modeLocation = cloneLocation(lower.modeLocation)
|
||||
}
|
||||
result.operation = collectionOperationMode(declaredMode, result.policy.explicitMode, len(result.effectiveIdentityFields) != 0)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectionOperationMode(current, retained sequenceMode, identified bool) sequenceMode {
|
||||
if current != 0 {
|
||||
return current
|
||||
}
|
||||
if retained != 0 {
|
||||
return retained
|
||||
}
|
||||
if identified {
|
||||
return sequenceModeUpsert
|
||||
}
|
||||
return sequenceModeReplace
|
||||
}
|
||||
|
||||
func cloneSequenceDeclarations(declarations []sequenceDeclarationSpec) []sequenceDeclarationSpec {
|
||||
if declarations == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]sequenceDeclarationSpec, len(declarations))
|
||||
for index, declaration := range declarations {
|
||||
cloned[index] = declaration
|
||||
cloned[index].path = declaration.path.clone()
|
||||
cloned[index].destination = declaration.destination.clone()
|
||||
cloned[index].identityFields = cloneStrings(declaration.identityFields)
|
||||
cloned[index].identityLocation = cloneLocation(declaration.identityLocation)
|
||||
cloned[index].modeLocation = cloneLocation(declaration.modeLocation)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func collectionDeclarationLocation(specific *Location, fallback Location) *Location {
|
||||
if specific != nil {
|
||||
return cloneLocation(specific)
|
||||
}
|
||||
if fallback != (Location{}) {
|
||||
return cloneLocation(&fallback)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectionPolicyConflictDiagnostic(path Path, primary, related *Location, message string) Diagnostic {
|
||||
diagnostic := Diagnostic{
|
||||
Code: CodeSequenceConflict, Severity: SeverityError,
|
||||
Path: path.clone(), Message: message, Primary: cloneLocation(primary),
|
||||
}
|
||||
if related != nil {
|
||||
diagnostic.Related = []Location{*cloneLocation(related)}
|
||||
}
|
||||
return diagnostic
|
||||
}
|
||||
258
internal/engine/collection_policy_state.go
Normal file
258
internal/engine/collection_policy_state.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type collectionPolicyScope struct {
|
||||
collections map[string]collectionPolicyEntry
|
||||
}
|
||||
|
||||
type collectionPolicyEntry struct {
|
||||
destination Path
|
||||
policy collectionPolicy
|
||||
}
|
||||
|
||||
type collectionRecordPolicy struct {
|
||||
owner *collectionRecordOwner
|
||||
scope collectionPolicyScope
|
||||
}
|
||||
|
||||
// The nonzero field ensures simultaneously live record owners have distinct
|
||||
// addresses. The token is per-record ownership, not a source or identity key.
|
||||
type collectionRecordOwner struct {
|
||||
marker byte
|
||||
}
|
||||
|
||||
type projectedCollectionPolicy struct {
|
||||
path Path
|
||||
policy collectionPolicy
|
||||
}
|
||||
|
||||
func newCollectionRecordPolicy() collectionRecordPolicy {
|
||||
return collectionRecordPolicy{owner: &collectionRecordOwner{marker: 1}}
|
||||
}
|
||||
|
||||
func retainedCollectionRecordPolicy(record collectionRecordPolicy) collectionRecordPolicy {
|
||||
return collectionRecordPolicy{
|
||||
owner: record.owner,
|
||||
scope: cloneCollectionPolicyScope(record.scope),
|
||||
}
|
||||
}
|
||||
|
||||
func replacedCollectionRecordPolicy() collectionRecordPolicy {
|
||||
return newCollectionRecordPolicy()
|
||||
}
|
||||
|
||||
func (policy *collectionPolicy) setRecordPolicy(record collectionRecordPolicy) {
|
||||
if record.owner == nil {
|
||||
record.owner = newCollectionRecordPolicy().owner
|
||||
}
|
||||
if policy.recordPolicies == nil {
|
||||
policy.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope)
|
||||
}
|
||||
if _, exists := policy.recordPolicies[record.owner]; !exists {
|
||||
policy.recordOrder = append(policy.recordOrder, record.owner)
|
||||
}
|
||||
policy.recordPolicies[record.owner] = cloneCollectionPolicyScope(record.scope)
|
||||
}
|
||||
|
||||
func (policy *collectionPolicy) setRecordPolicyAt(index int, record collectionRecordPolicy) {
|
||||
if index < 0 {
|
||||
return
|
||||
}
|
||||
if record.owner == nil {
|
||||
record.owner = newCollectionRecordPolicy().owner
|
||||
}
|
||||
if policy.recordPolicies == nil {
|
||||
policy.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope)
|
||||
}
|
||||
for len(policy.recordOrder) <= index {
|
||||
owner := newCollectionRecordPolicy().owner
|
||||
policy.recordOrder = append(policy.recordOrder, owner)
|
||||
policy.recordPolicies[owner] = collectionPolicyScope{}
|
||||
}
|
||||
delete(policy.recordPolicies, policy.recordOrder[index])
|
||||
policy.recordOrder[index] = record.owner
|
||||
policy.recordPolicies[record.owner] = cloneCollectionPolicyScope(record.scope)
|
||||
}
|
||||
|
||||
func (policy collectionPolicy) recordPolicyAt(index int) (collectionRecordPolicy, bool) {
|
||||
if index < 0 || index >= len(policy.recordOrder) {
|
||||
return collectionRecordPolicy{}, false
|
||||
}
|
||||
owner := policy.recordOrder[index]
|
||||
scope, ok := policy.recordPolicies[owner]
|
||||
if !ok {
|
||||
return collectionRecordPolicy{}, false
|
||||
}
|
||||
return collectionRecordPolicy{owner: owner, scope: cloneCollectionPolicyScope(scope)}, true
|
||||
}
|
||||
|
||||
func collectionPolicyForReplacement(lower *collectionPolicy, current collectionPolicy) collectionPolicy {
|
||||
result := cloneCollectionPolicy(current)
|
||||
if !current.explicitModePresent && lower != nil && lower.explicitModePresent {
|
||||
result.explicitMode = lower.explicitMode
|
||||
result.explicitModePresent = true
|
||||
result.modeLocation = cloneLocation(lower.modeLocation)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// collectionPolicyWithoutValue keeps identity and record ownership because no
|
||||
// collection value was replaced. A key-only declaration can remain pending
|
||||
// when no sequence is present, or validate the retained sequence immediately.
|
||||
func collectionPolicyWithoutValue(
|
||||
lower *collectionPolicy,
|
||||
current collectionPolicy,
|
||||
retainedValue *document.Node,
|
||||
path Path,
|
||||
) (collectionPolicy, []Diagnostic) {
|
||||
result := collectionPolicy{}
|
||||
if lower != nil {
|
||||
result = cloneCollectionPolicy(*lower)
|
||||
}
|
||||
if lower != nil && lower.identityPresent && current.identityPresent && !equalSequenceIdentityFields(lower.identityFields, current.identityFields) {
|
||||
return collectionPolicy{}, []Diagnostic{collectionPolicyConflictDiagnostic(
|
||||
path, current.identityLocation, lower.identityLocation,
|
||||
"collection identity declaration conflicts with retained identity",
|
||||
)}
|
||||
}
|
||||
if current.identityPresent {
|
||||
result.identityFields = cloneStrings(current.identityFields)
|
||||
result.identityPresent = true
|
||||
result.identityLocation = cloneLocation(current.identityLocation)
|
||||
}
|
||||
if current.explicitModePresent {
|
||||
result.explicitMode = current.explicitMode
|
||||
result.explicitModePresent = true
|
||||
result.modeLocation = cloneLocation(current.modeLocation)
|
||||
}
|
||||
if current.identityPresent && retainedValue != nil && retainedValue.Kind == document.Sequence {
|
||||
_, diagnostics := validateSequenceIdentity(retainedValue, path, result.identityFields, sequenceIdentityDiagnosticCodes{
|
||||
invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict,
|
||||
})
|
||||
if len(diagnostics) != 0 {
|
||||
return collectionPolicy{}, diagnostics
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (scope *collectionPolicyScope) set(destination Path, policy collectionPolicy) {
|
||||
if scope.collections == nil {
|
||||
scope.collections = make(map[string]collectionPolicyEntry)
|
||||
}
|
||||
scope.collections[destination.String()] = collectionPolicyEntry{
|
||||
destination: destination.clone(), policy: cloneCollectionPolicy(policy),
|
||||
}
|
||||
}
|
||||
|
||||
func (scope collectionPolicyScope) at(destination Path) (collectionPolicy, bool) {
|
||||
entry, ok := scope.collections[destination.String()]
|
||||
if !ok {
|
||||
return collectionPolicy{}, false
|
||||
}
|
||||
return cloneCollectionPolicy(entry.policy), true
|
||||
}
|
||||
|
||||
// retireCollectionPoliciesAtOrBelow clears YAML identity and record-owned state
|
||||
// below a replaced ancestor while retaining independently scoped explicit modes.
|
||||
func (scope *collectionPolicyScope) retireCollectionPoliciesAtOrBelow(ancestor Path) {
|
||||
if scope == nil {
|
||||
return
|
||||
}
|
||||
for key, entry := range scope.collections {
|
||||
if !pathHasPrefix(entry.destination, ancestor) {
|
||||
continue
|
||||
}
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
scope.collections[key] = entry
|
||||
}
|
||||
}
|
||||
|
||||
func projectCollectionPolicyScope(scope collectionPolicyScope, prefix Path) []projectedCollectionPolicy {
|
||||
keys := make([]string, 0, len(scope.collections))
|
||||
for key := range scope.collections {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var projected []projectedCollectionPolicy
|
||||
for _, key := range keys {
|
||||
entry := scope.collections[key]
|
||||
concrete := appendDestination(prefix, entry.destination)
|
||||
projected = append(projected, projectedCollectionPolicy{path: concrete, policy: cloneCollectionPolicy(entry.policy)})
|
||||
for index, owner := range entry.policy.recordOrder {
|
||||
if recordScope, ok := entry.policy.recordPolicies[owner]; ok {
|
||||
projected = append(projected, projectCollectionPolicyScope(recordScope, concrete.Index(index))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
func cloneCollectionPolicyScope(scope collectionPolicyScope) collectionPolicyScope {
|
||||
if scope.collections == nil {
|
||||
return collectionPolicyScope{}
|
||||
}
|
||||
cloned := collectionPolicyScope{collections: make(map[string]collectionPolicyEntry, len(scope.collections))}
|
||||
for key, entry := range scope.collections {
|
||||
cloned.collections[key] = collectionPolicyEntry{
|
||||
destination: entry.destination.clone(), policy: cloneCollectionPolicy(entry.policy),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCollectionPolicyScopeForInheritance(scope collectionPolicyScope) collectionPolicyScope {
|
||||
if scope.collections == nil {
|
||||
return collectionPolicyScope{}
|
||||
}
|
||||
cloned := collectionPolicyScope{collections: make(map[string]collectionPolicyEntry, len(scope.collections))}
|
||||
for key, entry := range scope.collections {
|
||||
cloned.collections[key] = collectionPolicyEntry{
|
||||
destination: entry.destination.clone(), policy: cloneCollectionPolicyForInheritance(entry.policy),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCollectionPolicy(policy collectionPolicy) collectionPolicy {
|
||||
cloned := policy
|
||||
cloned.identityFields = cloneStrings(policy.identityFields)
|
||||
cloned.identityLocation = cloneLocation(policy.identityLocation)
|
||||
cloned.modeLocation = cloneLocation(policy.modeLocation)
|
||||
if policy.recordOrder != nil {
|
||||
cloned.recordOrder = append([]*collectionRecordOwner(nil), policy.recordOrder...)
|
||||
}
|
||||
if policy.recordPolicies != nil {
|
||||
cloned.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope, len(policy.recordPolicies))
|
||||
for owner, scope := range policy.recordPolicies {
|
||||
cloned.recordPolicies[owner] = cloneCollectionPolicyScope(scope)
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCollectionPolicyForInheritance(policy collectionPolicy) collectionPolicy {
|
||||
cloned := policy
|
||||
cloned.identityFields = cloneStrings(policy.identityFields)
|
||||
cloned.identityLocation = cloneLocation(policy.identityLocation)
|
||||
cloned.modeLocation = cloneLocation(policy.modeLocation)
|
||||
if policy.recordOrder != nil {
|
||||
cloned.recordOrder = make([]*collectionRecordOwner, 0, len(policy.recordOrder))
|
||||
}
|
||||
if policy.recordPolicies != nil {
|
||||
cloned.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope, len(policy.recordPolicies))
|
||||
for _, sourceOwner := range policy.recordOrder {
|
||||
owner := newCollectionRecordPolicy().owner
|
||||
cloned.recordOrder = append(cloned.recordOrder, owner)
|
||||
if sourceScope, ok := policy.recordPolicies[sourceOwner]; ok {
|
||||
cloned.recordPolicies[owner] = cloneCollectionPolicyScopeForInheritance(sourceScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
292
internal/engine/collection_policy_state_test.go
Normal file
292
internal/engine/collection_policy_state_test.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) {
|
||||
lower := collectionPolicy{
|
||||
identityFields: []string{"id"},
|
||||
identityPresent: true,
|
||||
explicitMode: sequenceModeAppend,
|
||||
explicitModePresent: true,
|
||||
}
|
||||
lower.setRecordPolicy(newCollectionRecordPolicy())
|
||||
current := collectionPolicy{explicitMode: sequenceModeReplace, explicitModePresent: true}
|
||||
retained, diagnostics := collectionPolicyWithoutValue(&lower, current, nil, Keys("prompts"))
|
||||
if len(diagnostics) != 0 || !retained.identityPresent || !reflect.DeepEqual(retained.identityFields, []string{"id"}) {
|
||||
t.Fatalf("no-value replace reset identity: %#v, %#v", retained, diagnostics)
|
||||
}
|
||||
if retained.explicitMode != sequenceModeReplace || !retained.explicitModePresent || len(retained.recordOrder) != 1 {
|
||||
t.Fatalf("no-value policy update lost mode or record ownership: %#v", retained)
|
||||
}
|
||||
retainedRecord, _ := retained.recordPolicyAt(0)
|
||||
lowerRecord, _ := lower.recordPolicyAt(0)
|
||||
if retainedRecord.owner != lowerRecord.owner {
|
||||
t.Fatal("no-value policy update changed record ownership")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyWithoutValueLeavesAbsentAndNonSequenceDestinationsUnconstrained(t *testing.T) {
|
||||
current := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
for _, value := range []*document.Node{nil, parsePolicyNode(t, "value: scalar\n").Mapping[0].Value} {
|
||||
retained, diagnostics := collectionPolicyWithoutValue(nil, current, value, Keys("prompts"))
|
||||
if len(diagnostics) != 0 || !retained.identityPresent || !reflect.DeepEqual(retained.identityFields, []string{"id"}) {
|
||||
t.Fatalf("pending identity at absent/non-sequence destination = %#v, %#v", retained, diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyWithoutValueValidatesRetainedSequenceImmediately(t *testing.T) {
|
||||
current := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
valid := parsePolicyNode(t, "items: [{id: one}, {id: two}]\n").Mapping[0].Value
|
||||
retained, diagnostics := collectionPolicyWithoutValue(nil, current, valid, Keys("prompts"))
|
||||
if len(diagnostics) != 0 || !retained.identityPresent {
|
||||
t.Fatalf("valid retained records were rejected: %#v, %#v", retained, diagnostics)
|
||||
}
|
||||
|
||||
invalid := parsePolicyNode(t, "items: [{name: missing-id}]\n").Mapping[0].Value
|
||||
retained, diagnostics = collectionPolicyWithoutValue(nil, current, invalid, Keys("prompts"))
|
||||
if !reflect.DeepEqual(retained, collectionPolicy{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/prompts/0/id" {
|
||||
t.Fatalf("invalid retained records = %#v, %#v", retained, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyWithoutValueRejectsIdentityChange(t *testing.T) {
|
||||
lower := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
current := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||
retained, diagnostics := collectionPolicyWithoutValue(&lower, current, nil, Keys("prompts"))
|
||||
if !reflect.DeepEqual(retained, collectionPolicy{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||
t.Fatalf("no-value identity change = %#v, %#v", retained, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyLifetimePersistenceAndReplacementTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current collectionPolicy
|
||||
replaceValue bool
|
||||
wantIdentity []string
|
||||
wantIdentitySet bool
|
||||
wantMode sequenceMode
|
||||
wantRecords int
|
||||
}{
|
||||
{
|
||||
name: "mode-only replace declaration does not reset retained identity",
|
||||
current: collectionPolicy{
|
||||
explicitMode: sequenceModeReplace, explicitModePresent: true,
|
||||
},
|
||||
wantIdentity: []string{"id"}, wantIdentitySet: true,
|
||||
wantMode: sequenceModeReplace, wantRecords: 1,
|
||||
},
|
||||
{
|
||||
name: "whole-sequence replacement resets lower identity",
|
||||
replaceValue: true,
|
||||
wantIdentitySet: false,
|
||||
wantMode: sequenceModeAppend,
|
||||
wantRecords: 0,
|
||||
},
|
||||
{
|
||||
name: "replacement can establish a new identity",
|
||||
current: collectionPolicy{identityFields: []string{"name"}, identityPresent: true},
|
||||
replaceValue: true,
|
||||
wantIdentity: []string{"name"},
|
||||
wantIdentitySet: true,
|
||||
wantMode: sequenceModeAppend,
|
||||
wantRecords: 0,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lower := collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
}
|
||||
lower.setRecordPolicy(newCollectionRecordPolicy())
|
||||
var got collectionPolicy
|
||||
if test.replaceValue {
|
||||
got = collectionPolicyForReplacement(&lower, test.current)
|
||||
} else {
|
||||
var diagnostics []Diagnostic
|
||||
got, diagnostics = collectionPolicyWithoutValue(&lower, test.current, nil, Keys("prompts"))
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
if got.identityPresent != test.wantIdentitySet || !reflect.DeepEqual(got.identityFields, test.wantIdentity) || got.explicitMode != test.wantMode || len(got.recordOrder) != test.wantRecords {
|
||||
t.Fatalf("transition result = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyValueReplacementResetsIdentityButRetainsExplicitMode(t *testing.T) {
|
||||
lower := collectionPolicy{
|
||||
identityFields: []string{"id"},
|
||||
identityPresent: true,
|
||||
explicitMode: sequenceModeAppend,
|
||||
explicitModePresent: true,
|
||||
}
|
||||
lower.setRecordPolicy(newCollectionRecordPolicy())
|
||||
current := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||
replaced := collectionPolicyForReplacement(&lower, current)
|
||||
if !replaced.identityPresent || !reflect.DeepEqual(replaced.identityFields, []string{"name"}) {
|
||||
t.Fatalf("replacement identity was not reset: %#v", replaced)
|
||||
}
|
||||
if replaced.explicitMode != sequenceModeAppend || !replaced.explicitModePresent || len(replaced.recordOrder) != 0 {
|
||||
t.Fatalf("replacement failed to retain explicit mode or retire lower record policy: %#v", replaced)
|
||||
}
|
||||
|
||||
cleared := collectionPolicyForReplacement(&lower, collectionPolicy{})
|
||||
if cleared.identityPresent || len(cleared.identityFields) != 0 || cleared.explicitMode != sequenceModeAppend || !cleared.explicitModePresent {
|
||||
t.Fatalf("replacement without a new key did not clear YAML identity while retaining mode: %#v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionRecordRetentionAndReplacementUseOpaqueOwners(t *testing.T) {
|
||||
oldRecord := newCollectionRecordPolicy()
|
||||
oldRecord.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
retained := retainedCollectionRecordPolicy(oldRecord)
|
||||
if retained.owner != oldRecord.owner {
|
||||
t.Fatal("retained record did not keep its owner")
|
||||
}
|
||||
if got, ok := retained.scope.at(Keys("steps")); !ok || !got.identityPresent {
|
||||
t.Fatalf("retained record lost its local policy: %#v, %t", got, ok)
|
||||
}
|
||||
if retained.scope.collections["/steps"].policy.identityFields[0] = "changed"; oldRecord.scope.collections["/steps"].policy.identityFields[0] != "id" {
|
||||
t.Fatal("retained record policy aliases its source")
|
||||
}
|
||||
|
||||
// A whole-record replacement at the same list position, even with equal
|
||||
// authored identity/source metadata, receives new ownership and no old rules.
|
||||
replacement := replacedCollectionRecordPolicy()
|
||||
if replacement.owner == oldRecord.owner || len(replacement.scope.collections) != 0 {
|
||||
t.Fatal("replacement record inherited discarded owner policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyAncestorRetirementPreservesIndependentMode(t *testing.T) {
|
||||
var scope collectionPolicyScope
|
||||
owner := newCollectionRecordPolicy()
|
||||
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
scope.set(Keys("catalog", "prompts"), collectionPolicyWithRecords(collectionPolicy{
|
||||
identityFields: []string{"id"},
|
||||
identityPresent: true,
|
||||
explicitMode: sequenceModeAppend,
|
||||
explicitModePresent: true,
|
||||
}, owner))
|
||||
scope.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
policy, ok := scope.at(Keys("catalog", "prompts"))
|
||||
if !ok || policy.identityPresent || len(policy.recordOrder) != 0 || policy.explicitMode != sequenceModeAppend || !policy.explicitModePresent {
|
||||
t.Fatalf("ancestor retirement = %#v, %t", policy, ok)
|
||||
}
|
||||
reintroduced, diagnostics := collectionPolicyWithoutValue(&policy, collectionPolicy{}, nil, Keys("catalog", "prompts"))
|
||||
if len(diagnostics) != 0 || reintroduced.identityPresent || reintroduced.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("retired identity was resurrected while mode persisted: %#v, %#v", reintroduced, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualOuterIdentitiesHaveDistinctRecordOwners(t *testing.T) {
|
||||
retained := newCollectionRecordPolicy()
|
||||
replacement := replacedCollectionRecordPolicy()
|
||||
if retained.owner == replacement.owner {
|
||||
t.Fatal("replacement with the same outer identity reused the old owner")
|
||||
}
|
||||
if retained.owner == nil || replacement.owner == nil {
|
||||
t.Fatal("record owner token is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyScopesKeepRootAndItemRulesIndependent(t *testing.T) {
|
||||
var root collectionPolicyScope
|
||||
root.set(Keys("steps"), collectionPolicy{identityFields: []string{"root-id"}, identityPresent: true})
|
||||
owner := newCollectionRecordPolicy()
|
||||
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"item-id"}, identityPresent: true})
|
||||
root.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, owner))
|
||||
|
||||
root.retireCollectionPoliciesAtOrBelow(Keys("steps"))
|
||||
rootPolicy, ok := root.at(Keys("steps"))
|
||||
if !ok || rootPolicy.identityPresent {
|
||||
t.Fatalf("root-local policy was not retired: %#v", rootPolicy)
|
||||
}
|
||||
itemPolicy, ok := root.at(Keys("items"))
|
||||
if !ok || len(itemPolicy.recordOrder) != 1 {
|
||||
t.Fatalf("unrelated item owner disappeared: %#v", itemPolicy)
|
||||
}
|
||||
itemRecord, _ := itemPolicy.recordPolicyAt(0)
|
||||
nested, ok := itemRecord.scope.at(Keys("steps"))
|
||||
if !ok || !nested.identityPresent || !reflect.DeepEqual(nested.identityFields, []string{"item-id"}) {
|
||||
t.Fatalf("item-local policy was coupled to root destination: %#v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyProjectionUsesConcretePlacement(t *testing.T) {
|
||||
first := newCollectionRecordPolicy()
|
||||
first.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"step"}, identityPresent: true})
|
||||
second := newCollectionRecordPolicy()
|
||||
second.scope.set(Keys("steps"), collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
|
||||
var scope collectionPolicyScope
|
||||
scope.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, first, second))
|
||||
|
||||
projected := projectCollectionPolicyScope(scope, Keys("mounted"))
|
||||
if len(projected) != 3 {
|
||||
t.Fatalf("projected policies = %#v", projected)
|
||||
}
|
||||
want := []string{"/mounted/items", "/mounted/items/0/steps", "/mounted/items/1/steps"}
|
||||
for index, policy := range projected {
|
||||
if policy.path.String() != want[index] {
|
||||
t.Fatalf("projected path[%d] = %q, want %q", index, policy.path.String(), want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPolicyInheritanceCopiesOwnersAndState(t *testing.T) {
|
||||
owner := newCollectionRecordPolicy()
|
||||
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
original := collectionPolicyWithRecords(collectionPolicy{}, owner)
|
||||
copy := cloneCollectionPolicyForInheritance(original)
|
||||
copyOwner := copy.recordOrder[0]
|
||||
if copyOwner == owner.owner {
|
||||
t.Fatal("inheritance copy retained the source record owner")
|
||||
}
|
||||
copy.recordPolicies[copyOwner].collections["/steps"].policy.identityFields[0] = "changed"
|
||||
if owner.scope.collections["/steps"].policy.identityFields[0] != "id" {
|
||||
t.Fatal("inheritance copy aliases source policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquisitionScopeCloneOwnsCollectionPolicyCopy(t *testing.T) {
|
||||
owner := newCollectionRecordPolicy()
|
||||
owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
var scope acquisitionScope
|
||||
scope.collectionPolicies.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, owner))
|
||||
cloned := cloneAcquisitionScope(scope)
|
||||
clonedOwner := cloned.collectionPolicies.collections["/items"].policy.recordOrder[0]
|
||||
cloned.collectionPolicies.collections["/items"].policy.recordPolicies[clonedOwner].collections["/steps"].policy.identityFields[0] = "changed"
|
||||
sourceOwner := scope.collectionPolicies.collections["/items"].policy.recordOrder[0]
|
||||
if scope.collectionPolicies.collections["/items"].policy.recordPolicies[sourceOwner].collections["/steps"].policy.identityFields[0] != "id" {
|
||||
t.Fatal("acquisition scope clone aliases collection policy")
|
||||
}
|
||||
if clonedOwner != owner.owner {
|
||||
t.Fatal("acquisition scope clone changed record ownership")
|
||||
}
|
||||
}
|
||||
|
||||
func collectionPolicyWithRecords(policy collectionPolicy, records ...collectionRecordPolicy) collectionPolicy {
|
||||
for _, record := range records {
|
||||
policy.setRecordPolicy(record)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func parsePolicyNode(t *testing.T, input string) *document.Node {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes("collection-policy-state.yml", []byte(input)), "collection-policy-state", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse policy value: %#v", diagnostics)
|
||||
}
|
||||
return parsed.document
|
||||
}
|
||||
192
internal/engine/collection_policy_test.go
Normal file
192
internal/engine/collection_policy_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testing.T) {
|
||||
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: [namespace, id]
|
||||
mode: append
|
||||
- destination: [prompts]
|
||||
key: [id, namespace, id]
|
||||
- destination: [prompts]
|
||||
mode: append
|
||||
`, rootDirectiveContextSource).sequences
|
||||
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, nil, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if !resolved.declarationsPresent || !resolved.identityDeclarationPresent || !resolved.modeDeclarationPresent {
|
||||
t.Fatalf("declaration presence was lost: %#v", resolved)
|
||||
}
|
||||
wantFields := []string{"id", "namespace"}
|
||||
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
|
||||
t.Fatalf("effective identity = %#v / %#v, want %#v", resolved.policy.identityFields, resolved.effectiveIdentityFields, wantFields)
|
||||
}
|
||||
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
|
||||
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
|
||||
}
|
||||
if len(resolved.declarations) != 3 || resolved.declarations[0].identityLocation == nil || resolved.declarations[2].location.Line == 0 {
|
||||
t.Fatalf("declaration locations were not retained: %#v", resolved.declarations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "identity fields",
|
||||
body: `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
- destination: [prompts]
|
||||
key: name
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "explicit modes",
|
||||
body: `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
mode: append
|
||||
- destination: [prompts]
|
||||
mode: replace
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
declarations := extractSequenceDeclarationsYAML(t, test.body, rootDirectiveContextSource).sequences
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, nil, nil)
|
||||
if !reflect.DeepEqual(resolved, collectionPolicyResolution{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||
t.Fatalf("resolution/diagnostics = %#v / %#v", resolved, diagnostics)
|
||||
}
|
||||
diagnostic := diagnostics[0]
|
||||
if diagnostic.Path.String() != "/prompts" || diagnostic.Primary == nil || len(diagnostic.Related) != 1 {
|
||||
t.Fatalf("conflict does not identify both declaration sites: %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary.SourceID != "sequence-declarations" || diagnostic.Related[0].SourceID != "sequence-declarations" || diagnostic.Primary.Line == diagnostic.Related[0].Line {
|
||||
t.Fatalf("conflict locations = %#v and %#v", diagnostic.Primary, diagnostic.Related)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicySelectsCurrentThenRetainedExplicitMode(t *testing.T) {
|
||||
lower := &collectionPolicy{
|
||||
identityFields: []string{"id"},
|
||||
identityPresent: true,
|
||||
explicitMode: sequenceModeAppend,
|
||||
explicitModePresent: true,
|
||||
modeLocation: &Location{SourceID: "lower", Line: 2},
|
||||
}
|
||||
current := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
mode: upsert
|
||||
`, rootDirectiveContextSource).sequences
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), current, nil, lower)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || resolved.policy.explicitMode != sequenceModeUpsert || !resolved.policy.explicitModePresent {
|
||||
t.Fatalf("current mode did not override retained mode: %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
|
||||
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), nil, nil, lower)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeAppend || resolved.policy.explicitMode != sequenceModeAppend || !resolved.policy.explicitModePresent {
|
||||
t.Fatalf("omitted current mode did not retain lower explicit mode: %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
if !reflect.DeepEqual(resolved.effectiveIdentityFields, []string{"id"}) {
|
||||
t.Fatalf("retained identity = %#v", resolved.effectiveIdentityFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyInfersWithoutPersistingMode(t *testing.T) {
|
||||
keyOnly := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
`, rootDirectiveContextSource).sequences
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), keyOnly, nil, nil)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || resolved.policy.explicitModePresent {
|
||||
t.Fatalf("identity-based mode inference = %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
|
||||
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), nil, []string{"id"}, nil)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || resolved.policy.identityPresent || !resolved.applicationIdentityPresent {
|
||||
t.Fatalf("application identity was not kept distinct from YAML policy: %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
|
||||
noIdentity := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
`, rootDirectiveContextSource).sequences
|
||||
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), noIdentity, nil, nil)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeReplace || resolved.policy.explicitModePresent {
|
||||
t.Fatalf("unkeyed mode inference = %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
|
||||
explicitDefault := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
mode: replace
|
||||
`, rootDirectiveContextSource).sequences
|
||||
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), explicitDefault, nil, nil)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeReplace || !resolved.policy.explicitModePresent {
|
||||
t.Fatalf("explicit default mode lost its presence: %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyChecksKeyOnlyAgainstApplicationConstraint(t *testing.T) {
|
||||
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: [namespace, id]
|
||||
`, rootDirectiveContextSource).sequences
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, []string{"id", "namespace", "id"}, nil)
|
||||
if len(diagnostics) != 0 || resolved.operation != sequenceModeUpsert || !reflect.DeepEqual(resolved.effectiveIdentityFields, []string{"id", "namespace"}) {
|
||||
t.Fatalf("equivalent key-only declaration was rejected: %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
|
||||
resolved, diagnostics = resolveCollectionPolicy(Keys("prompts"), declarations, []string{"slug"}, nil)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||
t.Fatalf("conflicting key-only declaration = %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Path.String() != "/prompts" || len(diagnostics[0].Related) != 0 {
|
||||
t.Fatalf("application conflict diagnostic = %#v", diagnostics[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyChecksRetainedIdentityAgainstApplicationConstraint(t *testing.T) {
|
||||
lower := &collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), nil, []string{"name"}, lower)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/prompts" {
|
||||
t.Fatalf("retained/application conflict = %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyRequiresCurrentIdentityToAgreeWithRetainedIdentity(t *testing.T) {
|
||||
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: name
|
||||
`, rootDirectiveContextSource).sequences
|
||||
lower := &collectionPolicy{
|
||||
identityFields: []string{"id"},
|
||||
identityPresent: true,
|
||||
identityLocation: &Location{SourceID: "lower", Line: 4},
|
||||
}
|
||||
resolved, diagnostics := resolveCollectionPolicy(Keys("prompts"), declarations, nil, lower)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/prompts" {
|
||||
t.Fatalf("current/retained identity conflict = %#v, %#v", resolved, diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 || diagnostics[0].Related[0].SourceID != "lower" {
|
||||
t.Fatalf("current/retained conflict locations = %#v", diagnostics[0])
|
||||
}
|
||||
}
|
||||
81
internal/engine/collection_retirement.go
Normal file
81
internal/engine/collection_retirement.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
// A true entry replaces the collection itself as well as its descendants;
|
||||
// false records ordinary ancestor replacement without selecting a collection
|
||||
// operation at that exact path.
|
||||
type collectionReplacementPaths map[string]bool
|
||||
|
||||
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
|
||||
for length := 0; length <= len(path.segments); length++ {
|
||||
prefix := Path{segments: path.segments[:length]}
|
||||
inclusive, found := paths[prefix.String()]
|
||||
if found && (length < len(path.segments) || includeExact && inclusive) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lowerCollectionPoliciesForClaims makes replacement eligibility independent of
|
||||
// declaration order. It removes only old YAML identity/record state; new pending
|
||||
// declarations and application constraints are reconciled afterward.
|
||||
func lowerCollectionPoliciesForClaims(
|
||||
lower collectionPolicyScope,
|
||||
claims []compositionClaim,
|
||||
declarations map[string][]sequenceDeclarationSpec,
|
||||
application sequencePolicy,
|
||||
) (collectionPolicyScope, collectionReplacementPaths) {
|
||||
byPath := make(map[string]compositionClaim, len(claims))
|
||||
rootPath := ""
|
||||
if len(claims) != 0 {
|
||||
rootPath = claims[0].path.String()
|
||||
}
|
||||
for _, claim := range claims {
|
||||
byPath[claim.path.String()] = claim
|
||||
}
|
||||
replaced := make(collectionReplacementPaths)
|
||||
for key, claim := range byPath {
|
||||
old, hasOld := lower.collections[key]
|
||||
current := declarations[key]
|
||||
if claim.kind == document.Mapping && (key == rootPath || !hasOld && len(current) == 0) {
|
||||
continue // Ordinary mapping combination preserves descendants.
|
||||
}
|
||||
mode := sequenceMode(0)
|
||||
identity := old.policy.identityPresent
|
||||
for _, declaration := range current {
|
||||
identity = identity || declaration.identityPresent
|
||||
if mode == 0 && declaration.modePresent {
|
||||
mode = declaration.mode
|
||||
}
|
||||
}
|
||||
retainedMode := sequenceMode(0)
|
||||
if old.policy.explicitModePresent {
|
||||
retainedMode = old.policy.explicitMode
|
||||
}
|
||||
_, registered := application.identityFieldsAt(claim.path)
|
||||
mode = collectionOperationMode(mode, retainedMode, identity || registered)
|
||||
if mode == sequenceModeReplace {
|
||||
replaced[key] = true
|
||||
} else if claim.kind != document.Mapping && claim.kind != document.Sequence {
|
||||
replaced[key] = false
|
||||
}
|
||||
}
|
||||
result := cloneCollectionPolicyScope(lower)
|
||||
for key, entry := range result.collections {
|
||||
if replaced.contains(entry.destination, true) {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
result.collections[key] = entry
|
||||
}
|
||||
}
|
||||
return result, replaced
|
||||
}
|
||||
|
||||
func clearCollectionIdentity(policy *collectionPolicy) {
|
||||
policy.identityFields = nil
|
||||
policy.identityPresent = false
|
||||
policy.identityLocation = nil
|
||||
policy.recordOrder = nil
|
||||
policy.recordPolicies = nil
|
||||
}
|
||||
111
internal/engine/collection_retirement_test.go
Normal file
111
internal/engine/collection_retirement_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, value := range []string{"null", "42", "{}", "[{name: new}]"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
lower := "configkit:\n sequences: [{destination: [items], key: id}]\nitems: [{id: old}]\n"
|
||||
main := "configkit:\n sequences: [{destination: [items], key: name, mode: replace}]\nitems: " + value + "\n"
|
||||
loaded, err := testGroupedLoad(loadRequest[map[string]any]{
|
||||
main: loadInput{source: Bytes("main", []byte(main))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte(lower))}}}},
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("replacement retained obsolete identity: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAncestorRemovalCanEstablishPendingReplacementIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, inheritance := range []bool{false, true} {
|
||||
name := "tiers"
|
||||
if inheritance {
|
||||
name = "inheritance"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var loaded *testResult[map[string]any]
|
||||
var err error
|
||||
if inheritance {
|
||||
loaded, err = testLoad[map[string]any](Bytes("source", []byte(`configkit:
|
||||
sequences:
|
||||
- {destination: [base, group, items], key: id}
|
||||
- {destination: [child, group, items], key: name}
|
||||
base: {group: {items: [{id: old}]}}
|
||||
child:
|
||||
extends: base
|
||||
group: null
|
||||
grandchild:
|
||||
extends: child
|
||||
group: {items: [{name: new}]}
|
||||
`)))
|
||||
} else {
|
||||
loaded, err = testGroupedLoad(loadRequest[map[string]any]{
|
||||
main: loadInput{source: Bytes("main", []byte("group: {items: [{name: new}]}\n"))},
|
||||
underlays: []loadGroup{
|
||||
{inputs: []loadInput{{source: Bytes("base", []byte("configkit:\n sequences: [{destination: [group, items], key: id}]\ngroup: {items: [{id: old}]}\n"))}}},
|
||||
{inputs: []loadInput{{source: Bytes("remove", []byte("configkit:\n sequences: [{destination: [group, items], key: name}]\ngroup: null\n"))}}},
|
||||
},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := loaded.Config
|
||||
if inheritance {
|
||||
value = value["grandchild"].(map[string]any)
|
||||
}
|
||||
items := value["group"].(map[string]any)["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["name"] != "new" {
|
||||
t.Fatalf("reintroduced collection = %#v", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritanceAncestorRetirementDoesNotResurrectOldIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := testLoad[map[string]any](Bytes("source", []byte(`configkit:
|
||||
sequences: [{destination: [base, group, items], key: id}]
|
||||
base: {group: {items: [{id: old}]}}
|
||||
child:
|
||||
extends: base
|
||||
group: null
|
||||
grandchild:
|
||||
extends: child
|
||||
group: {items: [{name: new}]}
|
||||
`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, withValue := range []bool{false, true} {
|
||||
main := "configkit:\n sequences: [{destination: [items], key: name, mode: replace}]\n"
|
||||
if withValue {
|
||||
main += "items: null\n"
|
||||
}
|
||||
definition := Definition[map[string]any]{}
|
||||
if withValue {
|
||||
definition.sequenceIdentityRegistrations = []sequenceIdentityRegistration{{
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||
}}
|
||||
}
|
||||
_, err := testGroupedLoad(loadRequest[map[string]any]{
|
||||
main: loadInput{source: Bytes("main", []byte(main))}, definition: definition,
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("configkit:\n sequences: [{destination: [items], key: id}]\nitems: [{id: old}]\n"))}}}},
|
||||
})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeSequenceConflict {
|
||||
t.Fatalf("retirement bypassed identity agreement: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,43 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
import (
|
||||
"maps"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// composedDocument is the private handoff between source assembly and later
|
||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||
// origins retain every source that contributes to a composite mapping.
|
||||
type composedDocument struct {
|
||||
root *document.Node
|
||||
claims []compositionClaim
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
root *document.Node
|
||||
acquisition []acquisitionScope
|
||||
importGraph importInclusionGraph
|
||||
collectionPolicies collectionPolicyScope
|
||||
claims []compositionClaim
|
||||
sequenceRanges []sequenceRange
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
}
|
||||
|
||||
type sequenceHistoryMode uint8
|
||||
|
||||
const (
|
||||
sequenceHistoryReplace sequenceHistoryMode = iota + 1
|
||||
sequenceHistoryAppend
|
||||
sequenceHistoryMapped
|
||||
)
|
||||
|
||||
type sequenceHistoryContribution struct {
|
||||
path Path
|
||||
mode sequenceHistoryMode
|
||||
offset int
|
||||
length int
|
||||
items []sequenceItemProjection
|
||||
replacements []sequenceItemReplacement
|
||||
}
|
||||
|
||||
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
||||
@@ -29,18 +54,27 @@ func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||
// history application trees are immutable and retain their existing sharing.
|
||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||
importGraph: base.importGraph.clone(),
|
||||
collectionPolicies: cloneCollectionPolicyScope(base.collectionPolicies),
|
||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
}
|
||||
}
|
||||
|
||||
type compositionClaim struct {
|
||||
path Path
|
||||
kind document.Kind
|
||||
location Location
|
||||
node *document.Node // borrowed immutable contribution, not the assembly tree
|
||||
path Path
|
||||
kind document.Kind
|
||||
location Location
|
||||
sourceIndex int
|
||||
}
|
||||
|
||||
type compositionClaims struct {
|
||||
@@ -48,16 +82,90 @@ type compositionClaims struct {
|
||||
byPath map[string][]compositionClaim
|
||||
}
|
||||
|
||||
// sequenceRange records where one source's sequence items were assigned in the
|
||||
// composed sequence at path. End is exclusive.
|
||||
type sequenceRange struct {
|
||||
sourceIndex int
|
||||
path Path
|
||||
start int
|
||||
end int
|
||||
items []sequenceItemProjection
|
||||
projection sequenceItemProjectionIndex
|
||||
}
|
||||
|
||||
func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) {
|
||||
if assigned.projection.outputByInput != nil {
|
||||
return assigned.projection.outputForInput(inputIndex)
|
||||
}
|
||||
if assigned.items != nil {
|
||||
output, found := 0, false
|
||||
for _, item := range assigned.items {
|
||||
if item.inputIndex == inputIndex && inputIndex >= 0 && item.outputIndex >= 0 {
|
||||
output, found = item.outputIndex, true
|
||||
}
|
||||
}
|
||||
return output, found
|
||||
}
|
||||
if inputIndex < 0 || inputIndex >= assigned.end-assigned.start {
|
||||
return 0, false
|
||||
}
|
||||
return assigned.start + inputIndex, true
|
||||
}
|
||||
|
||||
func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) {
|
||||
if assigned.projection.inputByOutput != nil {
|
||||
return assigned.projection.inputForOutput(outputIndex)
|
||||
}
|
||||
if assigned.items != nil {
|
||||
input, found := 0, false
|
||||
for _, item := range assigned.items {
|
||||
if item.outputIndex == outputIndex && outputIndex >= 0 && item.inputIndex >= 0 {
|
||||
input, found = item.inputIndex, true
|
||||
}
|
||||
}
|
||||
return input, found
|
||||
}
|
||||
if outputIndex < assigned.start || outputIndex >= assigned.end {
|
||||
return 0, false
|
||||
}
|
||||
return outputIndex - assigned.start, true
|
||||
}
|
||||
|
||||
func indexSequenceRange(assigned sequenceRange) sequenceRange {
|
||||
if assigned.items != nil && assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil {
|
||||
assigned.projection = newSequenceItemProjectionIndex(assigned.items)
|
||||
}
|
||||
return assigned
|
||||
}
|
||||
|
||||
// composeDocuments recursively joins disjoint mapping entries. Source order
|
||||
// controls only output traversal and diagnostics: every non-mapping overlap is
|
||||
// rejected before a result is assembled.
|
||||
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||
return composeDocumentsWithPolicy(sequencePolicy{}, inputs...)
|
||||
}
|
||||
|
||||
// composeDocumentsWithPolicy composes one strict tier using the registered
|
||||
// sequence rules when policy contains registrations. The ordinary adapter above
|
||||
// deliberately preserves the existing unregistered behavior for current callers.
|
||||
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||
return composeDocumentsWithCollectionPolicy(policy, collectionPolicyScope{}, nil, inputs...)
|
||||
}
|
||||
|
||||
// composeDocumentsWithCollectionPolicy resolves collection declarations and
|
||||
// eligible lower policy before strictly composing completed peer documents.
|
||||
func composeDocumentsWithCollectionPolicy(
|
||||
policy sequencePolicy,
|
||||
lower collectionPolicyScope,
|
||||
declarations []sequenceDeclarationSpec,
|
||||
inputs ...*document.Node,
|
||||
) (*composedDocument, []Diagnostic) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
||||
}
|
||||
|
||||
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||
for _, input := range inputs {
|
||||
for sourceIndex, input := range inputs {
|
||||
if input == nil || input.Kind != document.Mapping {
|
||||
var location *Location
|
||||
if input != nil {
|
||||
@@ -66,7 +174,14 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
||||
}
|
||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
|
||||
}
|
||||
collected.append(input, Path{})
|
||||
collected.append(input, Path{}, sourceIndex)
|
||||
}
|
||||
|
||||
identityFieldsByPath, collectionPolicies, diagnostics := resolveStrictCollectionPolicies(
|
||||
collected.ordered, policy, lower, declarations,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
checked := make(map[string]struct{}, len(collected.byPath))
|
||||
@@ -77,28 +192,53 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
||||
}
|
||||
checked[pointer] = struct{}{}
|
||||
claims := collected.byPath[pointer]
|
||||
if len(claims) < 2 || allMappingClaims(claims) {
|
||||
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && len(identityFieldsByPath[pointer]) != 0) {
|
||||
continue
|
||||
}
|
||||
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
||||
}
|
||||
|
||||
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, identityFieldsByPath)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
root := document.Clone(inputs[0])
|
||||
index := new(mappingIndex)
|
||||
for _, input := range inputs[1:] {
|
||||
mergeDisjointMappings(root, input, index)
|
||||
for sourceIndex, input := range inputs[1:] {
|
||||
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, identityFieldsByPath, validated)
|
||||
}
|
||||
applications := make([]historyApplication, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
||||
seenSequences := make(map[string]bool)
|
||||
rangesBySource := make([][]sequenceRange, len(inputs))
|
||||
for _, assigned := range ranges {
|
||||
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
|
||||
}
|
||||
origins := compositionOrigins(collected.ordered)
|
||||
for sourceIndex, input := range inputs {
|
||||
application := historyApplication{layer: LayerBase, value: input}
|
||||
for _, assigned := range rangesBySource[sourceIndex] {
|
||||
pointer := assigned.path.String()
|
||||
mode := sequenceHistoryAppend
|
||||
if !seenSequences[pointer] {
|
||||
mode = sequenceHistoryReplace
|
||||
}
|
||||
seenSequences[pointer] = true
|
||||
application.sequences = append(application.sequences, sequenceHistoryContribution{
|
||||
path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start,
|
||||
})
|
||||
}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
applications = append(applications, application)
|
||||
}
|
||||
origins := projectedCompositionOrigins(inputs, ranges)
|
||||
completeCompositionOrigins(root, Path{}, origins)
|
||||
return &composedDocument{
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
origins: origins,
|
||||
applications: applications,
|
||||
root: root,
|
||||
collectionPolicies: collectionPolicies,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
sequenceRanges: cloneSequenceRanges(ranges),
|
||||
origins: origins,
|
||||
applications: applications,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -121,11 +261,13 @@ func completeCompositionOrigins(node *document.Node, path Path, origins map[stri
|
||||
}
|
||||
}
|
||||
|
||||
func (c *compositionClaims) append(node *document.Node, path Path) {
|
||||
func (c *compositionClaims) append(node *document.Node, path Path, sourceIndex int) {
|
||||
claim := compositionClaim{
|
||||
path: path.clone(),
|
||||
kind: node.Kind,
|
||||
location: publicLocation(node.Location),
|
||||
node: node,
|
||||
path: path.clone(),
|
||||
kind: node.Kind,
|
||||
location: publicLocation(node.Location),
|
||||
sourceIndex: sourceIndex,
|
||||
}
|
||||
c.ordered = append(c.ordered, claim)
|
||||
pointer := path.String()
|
||||
@@ -135,10 +277,203 @@ func (c *compositionClaims) append(node *document.Node, path Path) {
|
||||
return
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
c.append(entry.Value, path.Key(entry.Key))
|
||||
c.append(entry.Value, path.Key(entry.Key), sourceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func allSequenceClaims(claims []compositionClaim) bool {
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Sequence {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type validatedSequence struct {
|
||||
items []*document.Node
|
||||
}
|
||||
|
||||
func resolveStrictCollectionPolicies(
|
||||
claims []compositionClaim,
|
||||
applicationPolicy sequencePolicy,
|
||||
lower collectionPolicyScope,
|
||||
declarations []sequenceDeclarationSpec,
|
||||
) (map[string][]string, collectionPolicyScope, []Diagnostic) {
|
||||
paths := make([]Path, 0, len(claims)+len(declarations))
|
||||
seen := make(map[string]bool, len(claims)+len(declarations))
|
||||
valuePresent := make(map[string]bool)
|
||||
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
|
||||
for _, claim := range claims {
|
||||
if len(lower.collections) != 0 {
|
||||
valuePresent[claim.path.String()] = true
|
||||
}
|
||||
if claim.kind != document.Sequence {
|
||||
continue
|
||||
}
|
||||
key := claim.path.String()
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
paths = append(paths, claim.path.clone())
|
||||
}
|
||||
}
|
||||
for _, declaration := range declarations {
|
||||
key := declaration.destination.String()
|
||||
declarationsByPath[key] = append(declarationsByPath[key], declaration)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
paths = append(paths, declaration.destination.clone())
|
||||
}
|
||||
}
|
||||
|
||||
identityFieldsByPath := make(map[string][]string)
|
||||
effectiveLower := lower
|
||||
if len(lower.collections) != 0 {
|
||||
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||
}
|
||||
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
|
||||
var diagnostics []Diagnostic
|
||||
for _, path := range paths {
|
||||
key := path.String()
|
||||
currentDeclarations := declarationsByPath[key]
|
||||
|
||||
lowerEntry, hasLower := effectiveLower.collections[key]
|
||||
var lowerPolicy *collectionPolicy
|
||||
if hasLower {
|
||||
copy := cloneCollectionPolicy(lowerEntry.policy)
|
||||
lowerPolicy = ©
|
||||
}
|
||||
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
|
||||
|
||||
resolution, found := resolveCollectionPolicy(path, currentDeclarations, applicationIdentity, lowerPolicy)
|
||||
if len(found) != 0 {
|
||||
diagnostics = append(diagnostics, found...)
|
||||
continue
|
||||
}
|
||||
if len(resolution.effectiveIdentityFields) != 0 {
|
||||
identityFieldsByPath[key] = cloneStrings(resolution.effectiveIdentityFields)
|
||||
}
|
||||
|
||||
policyForPath := resolution.policy
|
||||
if hasLower && !valuePresent[key] {
|
||||
// A declaration without a new value changes policy metadata but
|
||||
// leaves retained record ownership untouched.
|
||||
policyForPath = cloneCollectionPolicy(lowerEntry.policy)
|
||||
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
|
||||
policyForPath.identityPresent = resolution.policy.identityPresent
|
||||
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
|
||||
policyForPath.explicitMode = resolution.policy.explicitMode
|
||||
policyForPath.explicitModePresent = resolution.policy.explicitModePresent
|
||||
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
|
||||
}
|
||||
if collectionPolicies.collections == nil {
|
||||
collectionPolicies.collections = make(map[string]collectionPolicyEntry)
|
||||
}
|
||||
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
|
||||
}
|
||||
return identityFieldsByPath, collectionPolicies, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func validateRegisteredSequences(claims []compositionClaim, identityFieldsByPath map[string][]string) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
|
||||
itemsByPath := make(map[string]map[int]validatedSequence)
|
||||
identitiesByPath := make(map[string]map[string]document.Location)
|
||||
lengthsByPath := make(map[string]int)
|
||||
var ranges []sequenceRange
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Sequence {
|
||||
continue
|
||||
}
|
||||
identityFields := identityFieldsByPath[claim.path.String()]
|
||||
if len(identityFields) == 0 {
|
||||
continue
|
||||
}
|
||||
node := claim.node
|
||||
pathKey := claim.path.String()
|
||||
identities := identitiesByPath[pathKey]
|
||||
if identities == nil {
|
||||
identities = make(map[string]document.Location, len(node.Sequence))
|
||||
identitiesByPath[pathKey] = identities
|
||||
}
|
||||
start := lengthsByPath[pathKey]
|
||||
contribution := validatedSequence{items: node.Sequence}
|
||||
records, diagnostics := validateNormalizedSequenceIdentityFrom(node, claim.path, identityFields, sequenceIdentityDiagnosticCodes{
|
||||
invalid: CodeComposeInvalid, conflict: CodeComposeConflict,
|
||||
}, start, sequenceStructuralRecord)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
byIndex := make([]sequenceIdentityRecord, len(node.Sequence))
|
||||
for _, record := range records {
|
||||
byIndex[record.index] = record
|
||||
}
|
||||
for _, record := range byIndex {
|
||||
if first, duplicate := identities[record.tuple]; duplicate {
|
||||
current := publicLocation(record.location)
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeConflict, Severity: SeverityError,
|
||||
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
|
||||
Primary: ptrLocation(publicLocation(first)), Related: []Location{current},
|
||||
}}
|
||||
}
|
||||
identities[record.tuple] = record.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, identityFieldsByPath map[string][]string, 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 && len(identityFieldsByPath[entryPath.String()]) != 0 {
|
||||
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, identityFieldsByPath, validated)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
|
||||
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||
return
|
||||
}
|
||||
mergeComposedMappings(target, incoming, path, index, sourceIndex, identityFieldsByPath, validated)
|
||||
}
|
||||
|
||||
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
||||
cloned := make([]sequenceRange, len(ranges))
|
||||
for i, assigned := range ranges {
|
||||
cloned[i] = assigned
|
||||
cloned[i].path = assigned.path.clone()
|
||||
if assigned.items != nil {
|
||||
cloned[i].items = append([]sequenceItemProjection{}, assigned.items...)
|
||||
}
|
||||
cloned[i].projection.outputByInput = maps.Clone(assigned.projection.outputByInput)
|
||||
cloned[i].projection.inputByOutput = maps.Clone(assigned.projection.inputByOutput)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func allMappingClaims(claims []compositionClaim) bool {
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Mapping {
|
||||
@@ -172,18 +507,53 @@ func documentEntryIndex(entries []document.Entry, key string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func compositionOrigins(claims []compositionClaim) map[string]Origin {
|
||||
origins := make(map[string]Origin, len(claims))
|
||||
for _, claim := range claims {
|
||||
pointer := claim.path.String()
|
||||
origin := origins[pointer]
|
||||
origin.Layer = LayerBase
|
||||
origin.Locations = append(origin.Locations, claim.location)
|
||||
origins[pointer] = origin
|
||||
func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
|
||||
origins := make(map[string]Origin)
|
||||
sequencesBySource := make([][]sequenceHistoryContribution, len(inputs))
|
||||
for _, assigned := range ranges {
|
||||
contribution := sequenceHistoryContribution{
|
||||
path: assigned.path.clone(), mode: sequenceHistoryAppend,
|
||||
offset: assigned.start, length: assigned.end - assigned.start,
|
||||
}
|
||||
if assigned.items != nil {
|
||||
contribution.mode = sequenceHistoryMapped
|
||||
contribution.items = assigned.items
|
||||
}
|
||||
sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], contribution)
|
||||
}
|
||||
for sourceIndex, input := range inputs {
|
||||
application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
|
||||
pointer := effective.String()
|
||||
origin := origins[pointer]
|
||||
origin.Layer = LayerBase
|
||||
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
||||
origins[pointer] = origin
|
||||
})
|
||||
}
|
||||
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 {
|
||||
assigned = indexSequenceRange(assigned)
|
||||
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
||||
for pointer, origin := range origins {
|
||||
if _, present := decoded.presence[pointer]; present {
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
|
||||
registrations := make([]sequenceIdentityRegistration, 0, 128)
|
||||
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)
|
||||
registrations = append(registrations, sequenceIdentityRegistration{
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector(key)}, identityFields: []string{"id"},
|
||||
})
|
||||
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
|
||||
}
|
||||
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
|
||||
}
|
||||
policy := mustCompileSequenceIdentityPolicy(t, registrations)
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
working := &document.Node{Kind: document.Mapping}
|
||||
for _, application := range composed.applications {
|
||||
applyHistoryApplication(&working, application, new(mappingIndex))
|
||||
}
|
||||
if !configurationValuesEqual(working, composed.root) {
|
||||
t.Fatal("wide registered collections replay differs from assembly")
|
||||
}
|
||||
for collection := 0; collection < 128; collection++ {
|
||||
path := Keys(fmt.Sprintf("records%d", collection)).Index(2).Key("id")
|
||||
origin := composed.origins[path.String()]
|
||||
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
|
||||
t.Fatalf("shifted origin at %s = %#v", path.String(), origin)
|
||||
}
|
||||
local, ok := historyApplicationLocalPath(composed.applications[2], path)
|
||||
if !ok || local.String() != Keys(fmt.Sprintf("records%d", collection)).Index(0).Key("id").String() {
|
||||
t.Fatal("wide local path projection failed")
|
||||
}
|
||||
}
|
||||
clone := cloneComposedDocument(composed)
|
||||
for _, original := range composed.applications[0].sequences {
|
||||
pointer := original.path.String()
|
||||
copy := clone.applications[0].sequencesByPath[pointer]
|
||||
copy.path.segments[0].key = "changed"
|
||||
if composed.applications[0].sequencesByPath[pointer].path.String() != pointer {
|
||||
t.Fatal("history path index aliases cloned state")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
|
||||
base := parseCompositionDocument(t, "source-1", `services:
|
||||
api:
|
||||
@@ -190,6 +241,464 @@ 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 := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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 := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("mounted"), literalSequenceSelector("records")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("escaped/~"), literalSequenceSelector("7")}, identityFields: []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 TestProjectedCompositionOriginsUseNoncontiguousItemMappings(t *testing.T) {
|
||||
lowerRoot := parseCompositionDocument(t, "lower", `records:
|
||||
- id: first
|
||||
label: equal
|
||||
- id: middle
|
||||
- id: last
|
||||
nested: {old: lower}
|
||||
`)
|
||||
higherRoot := parseCompositionDocument(t, "higher", `records:
|
||||
- id: last
|
||||
nested: {new: higher}
|
||||
- id: first
|
||||
label: equal
|
||||
- id: added
|
||||
`)
|
||||
lower, _ := documentAtPath(lowerRoot, Keys("records"))
|
||||
higher, _ := documentAtPath(higherRoot, Keys("records"))
|
||||
combined, diagnostics := combineSequence(
|
||||
lower, higher, sequenceCombinationPolicy(sequenceModeUpsert, "id"),
|
||||
sequenceCombinationContext{destination: Keys("records")},
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("combine diagnostics = %#v", diagnostics)
|
||||
}
|
||||
inputs := []*document.Node{lowerRoot, higherRoot}
|
||||
ranges := []sequenceRange{
|
||||
{
|
||||
sourceIndex: 0, path: Keys("records"), start: 0, end: 3,
|
||||
items: combined.lowerItems,
|
||||
}, {
|
||||
sourceIndex: 1, path: Keys("records"), start: 0, end: 3,
|
||||
items: combined.higherItems,
|
||||
},
|
||||
}
|
||||
origins := projectedCompositionOrigins(inputs, ranges)
|
||||
if got, want := sequenceIdentityValues(combined.value, "id"), []string{"first", "middle", "last", "added"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("combined IDs = %#v, want %#v", got, want)
|
||||
}
|
||||
for path, want := range map[string][]string{
|
||||
"/records/0/label": {"higher"},
|
||||
"/records/1/id": {"lower"},
|
||||
"/records/2/nested/new": {"higher"},
|
||||
"/records/3/id": {"higher"},
|
||||
"/records/0/nested/new": nil,
|
||||
"/records/2/nested/old": nil,
|
||||
} {
|
||||
origin := origins[path]
|
||||
var got []string
|
||||
for _, location := range origin.Locations {
|
||||
got = append(got, location.SourceID)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("origin sources at %s = %#v, want %#v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSequenceRangesPreservesPreparedProjection(t *testing.T) {
|
||||
ranges := []sequenceRange{{
|
||||
start: 0, end: 1,
|
||||
projection: newSequenceItemProjectionIndex([]sequenceItemProjection{{inputIndex: 0, outputIndex: 4}}),
|
||||
}}
|
||||
cloned := cloneSequenceRanges(ranges)
|
||||
if output, ok := cloned[0].outputForInput(0); !ok || output != 4 {
|
||||
t.Fatalf("cloned projection = %d, %v; want output index 4", output, ok)
|
||||
}
|
||||
cloned[0].projection.outputByInput[0] = 9
|
||||
if output, ok := ranges[0].outputForInput(0); !ok || output != 4 {
|
||||
t.Fatalf("cloned projection aliases original: %d, %v", output, ok)
|
||||
}
|
||||
}
|
||||
|
||||
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/id", wantCode: CodeComposeInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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 TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
|
||||
path := Keys("records")
|
||||
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
|
||||
path.String(): {
|
||||
destination: path,
|
||||
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
|
||||
},
|
||||
}}
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, nil,
|
||||
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{id: second}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose strict peers with lower identity: %#v", diagnostics)
|
||||
}
|
||||
value, present := documentAtPath(composed.root, path)
|
||||
if !present || value.Kind != document.Sequence || len(value.Sequence) != 2 {
|
||||
t.Fatalf("composed records = %#v, %v; want two strict peer records", value, present)
|
||||
}
|
||||
resolved, present := composed.collectionPolicies.collections[path.String()]
|
||||
if !present || !resolved.policy.identityPresent || !reflect.DeepEqual(resolved.policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("composed collection policy = %#v, %v; want retained lower identity", resolved, present)
|
||||
}
|
||||
clone := cloneComposedDocument(composed)
|
||||
resolved.policy.identityFields[0] = "mutated"
|
||||
if !reflect.DeepEqual(clone.collectionPolicies.collections[path.String()].policy.identityFields, []string{"id"}) {
|
||||
t.Fatal("cloned composition shares mutable collection identity fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
|
||||
path := Keys("records")
|
||||
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
|
||||
location := Location{SourceID: source, Line: line}
|
||||
return sequenceDeclarationSpec{
|
||||
destination: path, identityFields: fields, identityPresent: true,
|
||||
identityLocation: &location,
|
||||
}
|
||||
}
|
||||
|
||||
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, collectionPolicyScope{},
|
||||
[]sequenceDeclarationSpec{declaration("source-1", 2, "id"), declaration("source-2", 4, "name")},
|
||||
parseCompositionDocument(t, "source-1", "left: true\n"),
|
||||
parseCompositionDocument(t, "source-2", "right: true\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/records" {
|
||||
t.Fatalf("conflicting policy-only declarations = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
|
||||
t.Fatalf("declaration conflict lacks both locations: %#v", diagnostics[0])
|
||||
}
|
||||
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, collectionPolicyScope{},
|
||||
[]sequenceDeclarationSpec{declaration("source-1", 2, "tenant", "id"), declaration("source-2", 4, "id", "tenant")},
|
||||
parseCompositionDocument(t, "source-1", "records: [{tenant: true, id: null}, {tenant: \"alpha\", id: 1}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{id: 1.0, tenant: \"beta\"}, {id: \"1\", tenant: \"alpha\"}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose normalized compound identities: %#v", diagnostics)
|
||||
}
|
||||
value, present := documentAtPath(composed.root, path)
|
||||
if !present || len(value.Sequence) != 4 {
|
||||
t.Fatalf("compound identity records = %#v, %v; want four distinct records", value, present)
|
||||
}
|
||||
|
||||
_, diagnostics = composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, collectionPolicyScope{},
|
||||
[]sequenceDeclarationSpec{declaration("source-1", 2, "id")},
|
||||
parseCompositionDocument(t, "source-1", "records: [{id: 1}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{id: 1.0}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||
t.Fatalf("equivalent numeric peer identities = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T) {
|
||||
path := Keys("records")
|
||||
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
|
||||
path.String(): {
|
||||
destination: path,
|
||||
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
|
||||
},
|
||||
}}
|
||||
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
|
||||
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||
t.Fatalf("replacement peers borrowing lower identity = %#v", diagnostics)
|
||||
}
|
||||
|
||||
identityReplacement := sequenceDeclarationSpec{
|
||||
destination: path, identityFields: []string{"name"}, identityPresent: true,
|
||||
mode: sequenceModeReplace, modePresent: true,
|
||||
}
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, []sequenceDeclarationSpec{identityReplacement},
|
||||
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("replacement with a new identity declaration: %#v", diagnostics)
|
||||
}
|
||||
resolved := composed.collectionPolicies.collections[path.String()].policy
|
||||
if !reflect.DeepEqual(resolved.identityFields, []string{"name"}) || !resolved.identityPresent {
|
||||
t.Fatalf("replacement collection identity = %#v; want name", resolved)
|
||||
}
|
||||
|
||||
_, diagnostics = composeDocumentsWithCollectionPolicy(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), lower,
|
||||
[]sequenceDeclarationSpec{declaration},
|
||||
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/0/id" {
|
||||
t.Fatalf("replacement ignored application identity constraint = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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/id" {
|
||||
t.Fatalf("diagnostics = %#v, want invalid identity field at /records/1/id", 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(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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 := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("absent")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("not-a-sequence")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("catalog")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("records")}, identityFields: []string{"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(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("registered")}, identityFields: []string{"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(
|
||||
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
|
||||
parseCompositionDocument(t, "source-1", test.first),
|
||||
parseCompositionDocument(t, "source-2", test.second),
|
||||
)
|
||||
assertCompositionConflict(t, diagnostics, "/records", []string{"source-1", "source-2"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
|
||||
|
||||
77
internal/engine/dependency_integration_test.go
Normal file
77
internal/engine/dependency_integration_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
|
||||
names := []string{"a-base", "b-independent", "z-dependent"}
|
||||
keys := make(map[string]importNodeKey, len(names))
|
||||
contributions := make([]acquiredContribution, len(names))
|
||||
for index, name := range names {
|
||||
key := importNodeKey{
|
||||
namespaceKind: "host", namespaceAnchor: "/shared",
|
||||
resourceKind: "file", resourcePath: "/" + name,
|
||||
}
|
||||
keys[name] = key
|
||||
contributions[index] = acquiredContribution{
|
||||
identity: key,
|
||||
source: SourceInfo{ID: "source-" + name, Path: name},
|
||||
}
|
||||
}
|
||||
|
||||
var want []string
|
||||
for _, reverse := range []bool{false, true} {
|
||||
graph := newImportInclusionGraph()
|
||||
orderedNames := append([]string(nil), names...)
|
||||
if reverse {
|
||||
orderedNames[0], orderedNames[2] = orderedNames[2], orderedNames[0]
|
||||
}
|
||||
for _, name := range orderedNames {
|
||||
graph.addNode(importGraphNode{key: keys[name], sourceID: "source-" + name, role: importRoleMapping})
|
||||
}
|
||||
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
||||
|
||||
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, graph)
|
||||
got := make([]string, len(scheduled))
|
||||
for index, contribution := range scheduled {
|
||||
got[index] = contribution.identity.resourcePath[1:]
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"a-base", "b-independent", "z-dependent"}) {
|
||||
t.Fatalf("import schedule reverse=%t: %v", reverse, got)
|
||||
}
|
||||
if !reflect.DeepEqual(
|
||||
[]string{contributions[0].source.ID, contributions[1].source.ID, contributions[2].source.ID},
|
||||
[]string{"source-a-base", "source-b-independent", "source-z-dependent"},
|
||||
) {
|
||||
t.Fatal("scheduling mutated acquisition source records")
|
||||
}
|
||||
|
||||
inheritanceText := "a-base: {value: ready}\nb-independent: {value: independent}\nz-dependent: {extends: a-base}\n"
|
||||
if reverse {
|
||||
inheritanceText = "z-dependent: {extends: a-base}\nb-independent: {value: independent}\na-base: {value: ready}\n"
|
||||
}
|
||||
scope := parseCompositionDocument(t, "inheritance", inheritanceText)
|
||||
inheritance, diagnostics := analyzeInheritanceGraph(scope, Path{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("analyze inheritance reverse=%t: %#v", reverse, diagnostics)
|
||||
}
|
||||
order, diagnostics := inheritance.schedule()
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("schedule inheritance reverse=%t: %#v", reverse, diagnostics)
|
||||
}
|
||||
inheritanceNames := make([]string, len(order))
|
||||
for index, node := range order {
|
||||
inheritanceNames[index] = inheritance.nodes[node].name
|
||||
}
|
||||
if !reflect.DeepEqual(inheritanceNames, got) {
|
||||
t.Fatalf("equivalent graph schedules differ reverse=%t: imports=%v inheritance=%v", reverse, got, inheritanceNames)
|
||||
}
|
||||
if want == nil {
|
||||
want = got
|
||||
} else if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("import graph construction changed schedule: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
internal/engine/dependency_order.go
Normal file
200
internal/engine/dependency_order.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// dependencyKey is an immutable tuple used to order dependency-graph nodes.
|
||||
// Callers provide unique keys and valid edge endpoints.
|
||||
type dependencyKey []string
|
||||
|
||||
// dependencyEdge points from a prerequisite to the node that depends on it.
|
||||
type dependencyEdge struct {
|
||||
prerequisite int
|
||||
dependent int
|
||||
}
|
||||
|
||||
type dependencyReadyHeap struct {
|
||||
nodes []int
|
||||
keys []dependencyKey
|
||||
}
|
||||
|
||||
func (h dependencyReadyHeap) Len() int { return len(h.nodes) }
|
||||
|
||||
func (h dependencyReadyHeap) Less(i, j int) bool {
|
||||
return dependencyKeyLess(h.keys[h.nodes[i]], h.keys[h.nodes[j]])
|
||||
}
|
||||
|
||||
func (h dependencyReadyHeap) Swap(i, j int) {
|
||||
h.nodes[i], h.nodes[j] = h.nodes[j], h.nodes[i]
|
||||
}
|
||||
|
||||
func (h *dependencyReadyHeap) Push(value any) {
|
||||
h.nodes = append(h.nodes, value.(int))
|
||||
}
|
||||
|
||||
func (h *dependencyReadyHeap) Pop() any {
|
||||
last := len(h.nodes) - 1
|
||||
node := h.nodes[last]
|
||||
h.nodes = h.nodes[:last]
|
||||
return node
|
||||
}
|
||||
|
||||
// dependencyKeyLess compares tuple components bytewise, with a shorter tuple
|
||||
// sorting first when all of its components match the corresponding prefix.
|
||||
func dependencyKeyLess(left, right dependencyKey) bool {
|
||||
for i := 0; i < len(left) && i < len(right); i++ {
|
||||
if left[i] != right[i] {
|
||||
return left[i] < right[i]
|
||||
}
|
||||
}
|
||||
return len(left) < len(right)
|
||||
}
|
||||
|
||||
// scheduleDependencies applies Kahn's algorithm, always selecting the
|
||||
// smallest currently-ready key. Duplicate edges count only once. If scheduling
|
||||
// stalls, an explicit depth-first traversal of the residual graph returns an
|
||||
// actual directed cycle, rotated to its smallest key and closed by repeating
|
||||
// its first node; blocked non-cycle dependents are excluded.
|
||||
//
|
||||
// Nodes are visited by precomputed key rank, which also makes each adjacency
|
||||
// list deterministic without sorting edges. The work is O(E + V log V), with
|
||||
// O(V + E) additional memory, apart from key copying and tuple comparisons.
|
||||
func scheduleDependencies(keys []dependencyKey, edges []dependencyEdge) (order, cycle []int) {
|
||||
ownedKeys := make([]dependencyKey, len(keys))
|
||||
for i, key := range keys {
|
||||
ownedKeys[i] = append(dependencyKey(nil), key...)
|
||||
}
|
||||
|
||||
nodeOrder := make([]int, len(ownedKeys))
|
||||
for i := range nodeOrder {
|
||||
nodeOrder[i] = i
|
||||
}
|
||||
sort.Slice(nodeOrder, func(i, j int) bool {
|
||||
return dependencyKeyLess(ownedKeys[nodeOrder[i]], ownedKeys[nodeOrder[j]])
|
||||
})
|
||||
|
||||
incoming := make([][]int, len(ownedKeys))
|
||||
for _, edge := range edges {
|
||||
incoming[edge.dependent] = append(incoming[edge.dependent], edge.prerequisite)
|
||||
}
|
||||
|
||||
outgoing := make([][]int, len(ownedKeys))
|
||||
indegree := make([]int, len(ownedKeys))
|
||||
for _, dependent := range nodeOrder {
|
||||
seen := make(map[int]struct{}, len(incoming[dependent]))
|
||||
for _, prerequisite := range incoming[dependent] {
|
||||
if _, duplicate := seen[prerequisite]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[prerequisite] = struct{}{}
|
||||
indegree[dependent]++
|
||||
// Dependents are visited by key rank, so each outgoing list is
|
||||
// already ordered by key.
|
||||
outgoing[prerequisite] = append(outgoing[prerequisite], dependent)
|
||||
}
|
||||
}
|
||||
|
||||
ready := dependencyReadyHeap{keys: ownedKeys}
|
||||
for _, node := range nodeOrder {
|
||||
if indegree[node] == 0 {
|
||||
ready.nodes = append(ready.nodes, node)
|
||||
}
|
||||
}
|
||||
heap.Init(&ready)
|
||||
|
||||
order = make([]int, 0, len(ownedKeys))
|
||||
for ready.Len() != 0 {
|
||||
node := heap.Pop(&ready).(int)
|
||||
order = append(order, node)
|
||||
for _, dependent := range outgoing[node] {
|
||||
indegree[dependent]--
|
||||
if indegree[dependent] == 0 {
|
||||
heap.Push(&ready, dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(order) == len(ownedKeys) {
|
||||
return order, nil
|
||||
}
|
||||
return nil, dependencyCycle(ownedKeys, nodeOrder, outgoing, indegree)
|
||||
}
|
||||
|
||||
type dependencyDFSFrame struct {
|
||||
node int
|
||||
next int
|
||||
}
|
||||
|
||||
// dependencyCycle searches the residual graph iteratively to avoid recursion
|
||||
// depth depending on input size.
|
||||
func dependencyCycle(keys []dependencyKey, nodeOrder []int, outgoing [][]int, indegree []int) []int {
|
||||
const (
|
||||
unvisited = uint8(0)
|
||||
active = uint8(1)
|
||||
finished = uint8(2)
|
||||
)
|
||||
|
||||
state := make([]uint8, len(keys))
|
||||
position := make([]int, len(keys))
|
||||
for i := range position {
|
||||
position[i] = -1
|
||||
}
|
||||
path := make([]int, 0, len(keys))
|
||||
frames := make([]dependencyDFSFrame, 0, len(keys))
|
||||
|
||||
for _, root := range nodeOrder {
|
||||
if indegree[root] == 0 || state[root] != unvisited {
|
||||
continue
|
||||
}
|
||||
state[root] = active
|
||||
position[root] = len(path)
|
||||
path = append(path, root)
|
||||
frames = append(frames, dependencyDFSFrame{node: root})
|
||||
|
||||
for len(frames) != 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if frame.next == len(outgoing[frame.node]) {
|
||||
state[frame.node] = finished
|
||||
position[frame.node] = -1
|
||||
path = path[:len(path)-1]
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
|
||||
next := outgoing[frame.node][frame.next]
|
||||
frame.next++
|
||||
if indegree[next] == 0 {
|
||||
continue
|
||||
}
|
||||
switch state[next] {
|
||||
case unvisited:
|
||||
state[next] = active
|
||||
position[next] = len(path)
|
||||
path = append(path, next)
|
||||
frames = append(frames, dependencyDFSFrame{node: next})
|
||||
case active:
|
||||
return canonicalDependencyCycle(keys, path[position[next]:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stalled Kahn traversal always leaves a cycle in the residual graph.
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalDependencyCycle(keys []dependencyKey, path []int) []int {
|
||||
start := 0
|
||||
for i := 1; i < len(path); i++ {
|
||||
if dependencyKeyLess(keys[path[i]], keys[path[start]]) {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
cycle := make([]int, len(path)+1)
|
||||
for i := range path {
|
||||
cycle[i] = path[(start+i)%len(path)]
|
||||
}
|
||||
cycle[len(path)] = cycle[0]
|
||||
return cycle
|
||||
}
|
||||
215
internal/engine/dependency_order_test.go
Normal file
215
internal/engine/dependency_order_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScheduleDependenciesOrdersReadyNodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keys []string
|
||||
edges [][2]string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "independent branches",
|
||||
keys: []string{"branch-c", "branch-a", "branch-b"},
|
||||
want: []string{"branch-a", "branch-b", "branch-c"},
|
||||
},
|
||||
{
|
||||
name: "unequal chains",
|
||||
keys: []string{"chain-d", "chain-c", "chain-b", "chain-a"},
|
||||
edges: [][2]string{
|
||||
{"chain-a", "chain-b"},
|
||||
{"chain-b", "chain-c"},
|
||||
{"chain-c", "chain-d"},
|
||||
},
|
||||
want: []string{"chain-a", "chain-b", "chain-c", "chain-d"},
|
||||
},
|
||||
{
|
||||
name: "multiple dependencies",
|
||||
keys: []string{"application", "right", "base", "left"},
|
||||
edges: [][2]string{
|
||||
{"base", "left"}, {"base", "right"},
|
||||
{"left", "application"}, {"right", "application"},
|
||||
},
|
||||
want: []string{"base", "left", "right", "application"},
|
||||
},
|
||||
{
|
||||
name: "shared dependency at equal depths",
|
||||
keys: []string{"sink", "right", "root", "left"},
|
||||
edges: [][2]string{
|
||||
{"root", "left"}, {"root", "right"},
|
||||
{"left", "sink"}, {"right", "sink"},
|
||||
},
|
||||
want: []string{"root", "left", "right", "sink"},
|
||||
},
|
||||
{
|
||||
name: "shared dependency at unequal depths",
|
||||
keys: []string{"sink", "deep", "side", "middle", "root"},
|
||||
edges: [][2]string{
|
||||
{"root", "middle"}, {"middle", "deep"}, {"root", "side"},
|
||||
{"deep", "sink"}, {"side", "sink"},
|
||||
},
|
||||
want: []string{"root", "middle", "deep", "side", "sink"},
|
||||
},
|
||||
{
|
||||
name: "newly ready small key precedes older large key",
|
||||
keys: []string{"z-ready", "a-new", "b-ready"},
|
||||
edges: [][2]string{{"b-ready", "a-new"}},
|
||||
want: []string{"b-ready", "a-new", "z-ready"},
|
||||
},
|
||||
{
|
||||
name: "duplicate edges count once",
|
||||
keys: []string{"dependent", "prerequisite"},
|
||||
edges: [][2]string{
|
||||
{"prerequisite", "dependent"},
|
||||
{"prerequisite", "dependent"},
|
||||
},
|
||||
want: []string{"prerequisite", "dependent"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
|
||||
order, cycle := scheduleDependencies(keys, edges)
|
||||
if len(cycle) != 0 {
|
||||
t.Fatalf("unexpected cycle: %v", dependencyNames(cycle, indexes))
|
||||
}
|
||||
if got := dependencyNames(order, indexes); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("schedule = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleDependenciesUsesTupleKeys(t *testing.T) {
|
||||
keys := []dependencyKey{
|
||||
{"a", "z"},
|
||||
{"a"},
|
||||
{"a", "aa"},
|
||||
{"A"},
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, nil)
|
||||
if len(cycle) != 0 {
|
||||
t.Fatalf("unexpected cycle: %v", cycle)
|
||||
}
|
||||
want := []int{3, 1, 2, 0}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("schedule = %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleDependenciesReportsActualCanonicalCycles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keys []string
|
||||
edges [][2]string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "self cycle",
|
||||
keys: []string{"self"},
|
||||
edges: [][2]string{{"self", "self"}},
|
||||
want: []string{"self", "self"},
|
||||
},
|
||||
{
|
||||
name: "multi-node cycle excludes blocked dependent",
|
||||
keys: []string{"blocked", "cycle-b", "cycle-a"},
|
||||
edges: [][2]string{
|
||||
{"cycle-a", "cycle-b"},
|
||||
{"cycle-b", "cycle-a"},
|
||||
{"cycle-b", "blocked"},
|
||||
},
|
||||
want: []string{"cycle-a", "cycle-b", "cycle-a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
|
||||
order, cycle := scheduleDependencies(keys, edges)
|
||||
if len(order) != 0 {
|
||||
t.Fatalf("unexpected schedule: %v", dependencyNames(order, indexes))
|
||||
}
|
||||
if got := dependencyNames(cycle, indexes); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("cycle = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleDependenciesIsStableAcrossInsertionPermutations(t *testing.T) {
|
||||
edges := [][2]string{
|
||||
{"base", "left"}, {"base", "right"},
|
||||
{"left", "sink"}, {"right", "sink"},
|
||||
}
|
||||
permutations := [][]string{
|
||||
{"sink", "right", "base", "left"},
|
||||
{"left", "base", "sink", "right"},
|
||||
{"right", "left", "base", "sink"},
|
||||
}
|
||||
for i, permutation := range permutations {
|
||||
keys, permutedEdges, indexes := dependencyGraph(permutation, edges)
|
||||
if i%2 != 0 {
|
||||
reverseDependencyEdges(permutedEdges)
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, permutedEdges)
|
||||
if len(cycle) != 0 {
|
||||
t.Fatalf("permutation %d returned cycle %v", i, dependencyNames(cycle, indexes))
|
||||
}
|
||||
if got, want := dependencyNames(order, indexes), []string{"base", "left", "right", "sink"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("permutation %d schedule = %v, want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
cycleEdges := [][2]string{{"b", "a"}, {"a", "b"}, {"b", "blocked"}}
|
||||
cyclePermutations := [][]string{{"blocked", "b", "a"}, {"a", "blocked", "b"}, {"b", "a", "blocked"}}
|
||||
for i, permutation := range cyclePermutations {
|
||||
keys, permutedEdges, indexes := dependencyGraph(permutation, cycleEdges)
|
||||
if i%2 != 0 {
|
||||
reverseDependencyEdges(permutedEdges)
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, permutedEdges)
|
||||
if len(order) != 0 {
|
||||
t.Fatalf("cycle permutation %d returned schedule %v", i, dependencyNames(order, indexes))
|
||||
}
|
||||
if got, want := dependencyNames(cycle, indexes), []string{"a", "b", "a"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("cycle permutation %d returned %v, want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dependencyGraph(nodeNames []string, namedEdges [][2]string) ([]dependencyKey, []dependencyEdge, map[int]string) {
|
||||
keys := make([]dependencyKey, len(nodeNames))
|
||||
indexes := make(map[string]int, len(nodeNames))
|
||||
for i, name := range nodeNames {
|
||||
keys[i] = dependencyKey{name}
|
||||
indexes[name] = i
|
||||
}
|
||||
edges := make([]dependencyEdge, len(namedEdges))
|
||||
for i, edge := range namedEdges {
|
||||
edges[i] = dependencyEdge{prerequisite: indexes[edge[0]], dependent: indexes[edge[1]]}
|
||||
}
|
||||
byIndex := make(map[int]string, len(nodeNames))
|
||||
for name, index := range indexes {
|
||||
byIndex[index] = name
|
||||
}
|
||||
return keys, edges, byIndex
|
||||
}
|
||||
|
||||
func dependencyNames(nodes []int, names map[int]string) []string {
|
||||
result := make([]string, len(nodes))
|
||||
for i, node := range nodes {
|
||||
result[i] = names[node]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func reverseDependencyEdges(edges []dependencyEdge) {
|
||||
for i, j := 0, len(edges)-1; i < j; i, j = i+1, j-1 {
|
||||
edges[i], edges[j] = edges[j], edges[i]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,33 @@ var (
|
||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||
)
|
||||
|
||||
type placementMode uint8
|
||||
|
||||
const (
|
||||
placementModeMapping placementMode = iota
|
||||
placementModeSequenceItem
|
||||
)
|
||||
|
||||
// PlacementModeFromFacade preserves root-owned numeric values until preflight
|
||||
// can reject unsupported modes.
|
||||
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
|
||||
|
||||
// PlacementModeForFacade converts private source evidence to the root-owned mode.
|
||||
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
|
||||
|
||||
// InputWithPlacement copies the root-owned mode into a private engine input.
|
||||
func InputWithPlacement(input Input, value uint8) Input {
|
||||
input.placementMode = placementMode(value)
|
||||
return input
|
||||
}
|
||||
|
||||
// OptionsWithPlacement copies root-owned controls into private engine options.
|
||||
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
|
||||
options.destination = destination.clone()
|
||||
options.placementMode = placementMode(value)
|
||||
return options
|
||||
}
|
||||
|
||||
func normalizeDestination(path Path) (Path, error) {
|
||||
if !path.valid() {
|
||||
return Path{}, errInvalidDestination
|
||||
@@ -47,18 +74,39 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
// placeDocument checks all per-document bounds before allocating destination
|
||||
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
// placeDocumentWithMode places a source mapping beneath its literal destination.
|
||||
// Sequence-item placement adds one sequence containing the complete mapping.
|
||||
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
normalized, err := normalizeDestination(destination)
|
||||
if err != nil {
|
||||
return Path{}, nil, err
|
||||
}
|
||||
if mode != placementModeMapping && mode != placementModeSequenceItem {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if mode == placementModeSequenceItem && len(normalized.segments) == 0 {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if body == nil {
|
||||
return normalized, nil, nil
|
||||
}
|
||||
if !destinationWithinLimits(body, normalized, maxDepth, maxNodes) {
|
||||
if mode == placementModeSequenceItem && body.Kind != document.Mapping {
|
||||
return normalized, nil, errInvalidDestination
|
||||
}
|
||||
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
|
||||
return normalized, nil, errDestinationLimit
|
||||
}
|
||||
|
||||
placed := body
|
||||
if mode == placementModeSequenceItem {
|
||||
placed = &document.Node{
|
||||
Kind: document.Sequence, Location: sourceRoot,
|
||||
Sequence: []*document.Node{placed},
|
||||
}
|
||||
}
|
||||
for index := len(normalized.segments) - 1; index >= 0; index-- {
|
||||
key := normalized.segments[index].key
|
||||
placed = &document.Node{
|
||||
@@ -77,28 +125,49 @@ func placeDocument(body *document.Node, destination Path, sourceRoot document.Lo
|
||||
|
||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
|
||||
if body == nil {
|
||||
return true
|
||||
}
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() {
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
|
||||
return false
|
||||
}
|
||||
segments := len(destination.segments)
|
||||
if segments > maxDepth || segments > maxNodes/2 {
|
||||
sequenceNodes := 0
|
||||
sequenceDepth := 0
|
||||
if mode == placementModeSequenceItem {
|
||||
if segments == 0 {
|
||||
return false
|
||||
}
|
||||
sequenceNodes = 1
|
||||
sequenceDepth = 1
|
||||
}
|
||||
if segments > maxDepth-sequenceDepth || segments > (maxNodes-sequenceNodes)/2 {
|
||||
return false
|
||||
}
|
||||
wrapperNodes := destinationWrapperNodeCount(destination)
|
||||
wrapperNodes := destinationWrapperNodeCount(destination) + sequenceNodes
|
||||
for _, segment := range destination.segments {
|
||||
if segment.kind != pathSegmentKey {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if documentDepth(body) > maxDepth-segments {
|
||||
if documentDepth(body) > maxDepth-segments-sequenceDepth {
|
||||
return false
|
||||
}
|
||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int {
|
||||
count := destinationWrapperNodeCount(destination)
|
||||
if mode == placementModeSequenceItem {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCount(destination Path) int {
|
||||
return 2 * len(destination.segments)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: empty, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
@@ -62,12 +62,72 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
||||
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||
charge := len(inputs) * perInput
|
||||
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedItemGlobsShareUnderlayNodeBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: children/*.yml
|
||||
destination: [children]
|
||||
placement: sequence-item
|
||||
id: parent
|
||||
`)
|
||||
fragmentPath := writeImportFile(t, directory, "children/a.yml", "id: child-a\n")
|
||||
parseNodes := func(path string) int {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(File(path), "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse %s: %#v", path, diagnostics)
|
||||
}
|
||||
return countInputNodes(parsed.document)
|
||||
}
|
||||
destination := Keys("groups")
|
||||
childDestination := appendDestination(destination, Keys("children"))
|
||||
otherDestination := Keys("other")
|
||||
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
||||
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "at aggregate bound"},
|
||||
{name: "one node below aggregate bound", delta: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
|
||||
}, {
|
||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
|
||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||
}
|
||||
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -100,7 +160,7 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: mainSource, imports: true, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
@@ -285,11 +345,11 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "optional missing path",
|
||||
body: "configkit: {imports: [{path: missing.yml, optional: true, destination: [unused]}]}\n",
|
||||
body: "configkit: {imports: [{path: missing.yml, destination: [unused]}]}\n",
|
||||
},
|
||||
{
|
||||
name: "optional unmatched glob",
|
||||
body: "configkit: {imports: [{glob: empty/*.yml, optional: true, destination: [unused]}]}\n",
|
||||
body: "configkit: {imports: [{glob: empty/*.yml, destination: [unused]}]}\n",
|
||||
setup: func(t *testing.T, directory string) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -327,7 +387,7 @@ func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounting := &importAccounting{}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
{source: NoSource(), destination: Keys("absent")},
|
||||
{source: Mapping("root", map[string]any{})},
|
||||
}}, 1, nil, accounting)
|
||||
@@ -371,7 +431,13 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||
overrides: []Override{override},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
input, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("assembly diagnostics = %#v", diagnostics)
|
||||
}
|
||||
_, diagnostics = preparePostStructuralOverrides(overridePreparationInput{
|
||||
root: input.document, inputBytes: accounting.bytes, inputNodes: accounting.nodes,
|
||||
}, input.sources, request.overrides, nil, limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
|
||||
t.Fatalf("override diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type importDestinationConfig struct {
|
||||
@@ -133,7 +135,11 @@ parent: true
|
||||
}
|
||||
}
|
||||
|
||||
composed, diagnostics := composeDocuments(state.documents...)
|
||||
documents := make([]*document.Node, 0, len(state.scope.contributions))
|
||||
for _, contribution := range state.scope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
composed, diagnostics := composeDocuments(documents...)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composition diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -445,7 +451,6 @@ func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: optional.yml
|
||||
optional: true
|
||||
`)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
||||
|
||||
@@ -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 }},
|
||||
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"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": "main", "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) != 3 || entries[0].(map[string]any)["id"] != "first" || entries[1].(map[string]any)["id"] != "peer" || entries[2].(map[string]any)["id"] != "main" {
|
||||
t.Fatalf("main item projection = %#v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type destinationDecodeProbe string
|
||||
|
||||
var destinationDecodeProbeValues []string
|
||||
|
||||
@@ -12,6 +12,98 @@ type destinationLoadConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
type destinationItemConfig struct {
|
||||
Catalog struct {
|
||||
Entries []map[string]string `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
|
||||
func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
|
||||
placementMode: placementModeSequenceItem,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0] == nil || len(result.Config.Catalog.Entries[0]) != 0 {
|
||||
t.Fatalf("empty mapping item = %#v", result.Config.Catalog.Entries)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "0")); ok {
|
||||
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved {
|
||||
t.Fatalf("source placement evidence = %#v", sources)
|
||||
}
|
||||
if got := sources[0].Destination.String(); got != "/catalog/entries" {
|
||||
t.Fatalf("source destination = %q", got)
|
||||
}
|
||||
|
||||
absent, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource item load: %v", err)
|
||||
}
|
||||
if got := len(testLoaded(absent).Config.Catalog.Entries); got != 0 {
|
||||
t.Fatalf("absent item created %d entries", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode placementMode
|
||||
path Path
|
||||
}{
|
||||
{name: "root item", mode: placementModeSequenceItem},
|
||||
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reader := bytes.NewBufferString("value: unused\n")
|
||||
_, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err == nil || reader.Len() == 0 {
|
||||
t.Fatalf("preflight err %v, unread bytes %d", err, reader.Len())
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("preflight error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := Load[destinationItemConfig](
|
||||
Mapping("ordinary", map[string]any{}), testSecretBinding(),
|
||||
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0] == nil {
|
||||
t.Fatalf("ordinary item placement = %#v", result.Config.Catalog.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadPlacesNonImportMainAndUnderlayInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
@@ -39,6 +40,29 @@ func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Mapping: []document.Entry{{
|
||||
Key: "name", Value: &document.Node{Kind: document.String, Text: "entry"},
|
||||
}},
|
||||
}
|
||||
destination := Keys("catalog", "entry")
|
||||
path, zero, err := placeDocument(body, destination, body.Location, 5, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path.String() != explicitPath.String() || !reflect.DeepEqual(zero, explicit) {
|
||||
t.Fatalf("zero placement (%q, %#v) differs from mapping mode (%q, %#v)", path.String(), zero, explicitPath.String(), explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDestinationCopiesLiteralSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -204,3 +228,60 @@ func TestPlaceDocumentRootAndBounds(t *testing.T) {
|
||||
t.Fatalf("absent body should not consume placement limits: node %p, error %v", absent, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := document.Location{SourceID: "item", Line: 1, Column: 1}
|
||||
body := &document.Node{Kind: document.Mapping, Location: root}
|
||||
destination := Keys("catalog", "entries")
|
||||
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if placedPath.String() != "/catalog/entries" || placed.Kind != document.Mapping {
|
||||
t.Fatalf("placed root = %q %#v", placedPath.String(), placed)
|
||||
}
|
||||
container, ok := documentAtPath(placed, destination)
|
||||
if !ok || container.Kind != document.Sequence || len(container.Sequence) != 1 || container.Sequence[0] != body {
|
||||
t.Fatalf("item destination = %#v, present %v", container, ok)
|
||||
}
|
||||
if got, want := countInputNodes(placed), countInputNodes(body)+destinationWrapperNodeCount(destination)+1; got != want {
|
||||
t.Fatalf("item node count = %d, want %d", got, want)
|
||||
}
|
||||
if container.Location != root || container.Sequence[0].Location != root {
|
||||
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
|
||||
}
|
||||
|
||||
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("root item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
depth int
|
||||
nodes int
|
||||
wantAccept bool
|
||||
}{
|
||||
{name: "exact allowance", depth: 3, nodes: 6, wantAccept: true},
|
||||
{name: "one too shallow", depth: 2, nodes: 6},
|
||||
{name: "one node short", depth: 3, nodes: 5},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes)
|
||||
if test.wantAccept && err != nil {
|
||||
t.Fatalf("exact bounds rejected: %v", err)
|
||||
}
|
||||
if !test.wantAccept && !errors.Is(err, errDestinationLimit) {
|
||||
t.Fatalf("over-bound placement error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,117 +9,104 @@ import (
|
||||
|
||||
const directiveKey = "configkit"
|
||||
|
||||
type importDeclaration struct {
|
||||
path string
|
||||
glob string
|
||||
destination Path
|
||||
optional bool
|
||||
location Location
|
||||
type rootDirectiveContext uint8
|
||||
|
||||
const (
|
||||
rootDirectiveContextImportsOnly rootDirectiveContext = iota + 1
|
||||
rootDirectiveContextSource
|
||||
rootDirectiveContextOverlay
|
||||
rootDirectiveContextLayered
|
||||
)
|
||||
|
||||
type rootDirectiveExtraction struct {
|
||||
body *document.Node
|
||||
imports []importDeclarationSpec
|
||||
sequences []sequenceDeclarationSpec
|
||||
importsPresent bool
|
||||
sequencesPresent bool
|
||||
}
|
||||
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||
extracted, diagnostics := extractRootDirectives(root, rootDirectiveContextImportsOnly)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
return extracted.body, extracted.imports, nil
|
||||
}
|
||||
|
||||
func extractRootDirectives(root *document.Node, context rootDirectiveContext) (rootDirectiveExtraction, []Diagnostic) {
|
||||
if root == nil {
|
||||
return rootDirectiveExtraction{}, nil
|
||||
}
|
||||
body := document.Clone(root)
|
||||
var declarations []importDeclaration
|
||||
directiveIndex := -1
|
||||
for index, entry := range body.Mapping {
|
||||
if entry.Key != directiveKey {
|
||||
continue
|
||||
}
|
||||
directiveIndex = index
|
||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
||||
extracted, diagnostics := parseRootDirectiveBlock(entry.Value, Keys(directiveKey), context)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
return rootDirectiveExtraction{}, diagnostics
|
||||
}
|
||||
declarations = parsed
|
||||
break
|
||||
}
|
||||
if directiveIndex >= 0 {
|
||||
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
||||
extracted.body = body
|
||||
if directiveIndex >= 0 {
|
||||
extracted.body.Mapping = append(extracted.body.Mapping[:directiveIndex], extracted.body.Mapping[directiveIndex+1:]...)
|
||||
}
|
||||
if diagnostics := nestedDirectiveDiagnostics(extracted.body, Path{}); len(diagnostics) != 0 {
|
||||
return rootDirectiveExtraction{}, diagnostics
|
||||
}
|
||||
return extracted, nil
|
||||
}
|
||||
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
return rootDirectiveExtraction{}, diagnostics
|
||||
}
|
||||
return body, declarations, nil
|
||||
return rootDirectiveExtraction{body: body}, nil
|
||||
}
|
||||
|
||||
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||
func parseRootDirectiveBlock(node *document.Node, directivePath Path, context rootDirectiveContext) (rootDirectiveExtraction, []Diagnostic) {
|
||||
if node == nil || node.Kind != document.Mapping {
|
||||
return rootDirectiveExtraction{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||
}
|
||||
var declarations []importDeclaration
|
||||
var extracted rootDirectiveExtraction
|
||||
var diagnostics []Diagnostic
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := directivePath.Key(entry.Key)
|
||||
if entry.Key != "imports" {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
|
||||
}
|
||||
if entry.Value.Kind != document.Sequence {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
|
||||
}
|
||||
for index, item := range entry.Value.Sequence {
|
||||
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
declarations = append(declarations, declaration)
|
||||
}
|
||||
}
|
||||
return declarations, nil
|
||||
}
|
||||
|
||||
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
||||
}
|
||||
declaration := importDeclaration{location: publicLocation(node.Location)}
|
||||
seenTarget := false
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := declarationPath.Key(entry.Key)
|
||||
switch entry.Key {
|
||||
case "path", "glob":
|
||||
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
|
||||
case "imports":
|
||||
extracted.importsPresent = true
|
||||
if context == rootDirectiveContextOverlay {
|
||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
||||
continue
|
||||
}
|
||||
seenTarget = true
|
||||
declaration.location = publicLocation(entry.Value.Location)
|
||||
if entry.Key == "path" {
|
||||
declaration.path = entry.Value.Text
|
||||
} else {
|
||||
declaration.glob = entry.Value.Text
|
||||
if context != rootDirectiveContextImportsOnly && context != rootDirectiveContextSource && context != rootDirectiveContextLayered {
|
||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "directive extraction context is invalid", entry.Value))
|
||||
continue
|
||||
}
|
||||
case "optional":
|
||||
if entry.Value.Kind != document.Boolean {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
||||
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
|
||||
extracted.imports = append(extracted.imports, parsed...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
case "sequences":
|
||||
extracted.sequencesPresent = true
|
||||
if context == rootDirectiveContextImportsOnly {
|
||||
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
||||
continue
|
||||
}
|
||||
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)}
|
||||
if context != rootDirectiveContextSource && context != rootDirectiveContextOverlay && context != rootDirectiveContextLayered {
|
||||
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "directive extraction context is invalid", entry.Value))
|
||||
continue
|
||||
}
|
||||
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
|
||||
parsed, issues := parseSequenceDeclarationDirective(entry.Value, fieldPath)
|
||||
extracted.sequences = append(extracted.sequences, parsed...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
default:
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
||||
}
|
||||
}
|
||||
if !seenTarget {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
||||
if len(diagnostics) != 0 {
|
||||
return rootDirectiveExtraction{}, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
return declaration, nil
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
@@ -145,26 +132,55 @@ func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
|
||||
}
|
||||
|
||||
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
||||
return fragmentVersionClaimProjected(root, rule, -1, nil)
|
||||
}
|
||||
|
||||
func fragmentVersionClaimProjected(root *document.Node, rule *VersionRule, sourceIndex int, ranges []sequenceRange) *document.Node {
|
||||
return fragmentVersionClaimWithIndex(root, rule, sourceIndex, indexSequenceRanges(ranges))
|
||||
}
|
||||
|
||||
func fragmentVersionClaimWithIndex(root *document.Node, rule *VersionRule, sourceIndex int, ranges sequenceRangeIndex) *document.Node {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
current := root
|
||||
currentPath := Path{}
|
||||
if len(rule.Path.segments) == 0 {
|
||||
return current
|
||||
}
|
||||
for _, segment := range rule.Path.segments {
|
||||
if current.Kind != document.Mapping {
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return current
|
||||
}
|
||||
index := documentEntryIndex(current.Mapping, segment.key)
|
||||
if index < 0 {
|
||||
return nil
|
||||
}
|
||||
current = current.Mapping[index].Value
|
||||
currentPath = currentPath.Key(segment.key)
|
||||
case document.Sequence:
|
||||
index, valid := schemaIndex(segment)
|
||||
if !valid {
|
||||
return current
|
||||
}
|
||||
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: currentPath.String()}]; found {
|
||||
projected, ok := assigned.inputForOutput(index)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
index = projected
|
||||
}
|
||||
if index < 0 || index >= len(current.Sequence) {
|
||||
return nil
|
||||
}
|
||||
current = current.Sequence[index]
|
||||
currentPath = currentPath.Index(index)
|
||||
default:
|
||||
// A scalar or null at an ancestor replaces the protected subtree.
|
||||
return current
|
||||
}
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return nil
|
||||
}
|
||||
key := segment.key
|
||||
index := documentEntryIndex(current.Mapping, key)
|
||||
if index < 0 {
|
||||
return nil
|
||||
}
|
||||
current = current.Mapping[index].Value
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -203,24 +219,28 @@ func nodeImportDiagnostic(code Code, path Path, message string, node *document.N
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||
}
|
||||
|
||||
func overlayDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
func overlayDirectiveDiagnostics(node *document.Node, current Path, allowSequences ...bool) []Diagnostic {
|
||||
sequencesAllowed := len(allowSequences) != 0 && allowSequences[0]
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := current.Key(entry.Key)
|
||||
if entry.Key == directiveKey {
|
||||
if sequencesAllowed && len(current.segments) == 0 && entry.Value != nil && entry.Value.Kind == document.Mapping && len(entry.Value.Mapping) == 1 && entry.Value.Mapping[0].Key == "sequences" {
|
||||
continue
|
||||
}
|
||||
location := publicLocation(entry.KeyLocation)
|
||||
return []Diagnostic{{
|
||||
Code: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
||||
Message: "overlays cannot contain configuration directives", Primary: &location,
|
||||
Message: "overlays may contain only root collection sequence declarations", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath, sequencesAllowed); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
if diagnostics := overlayDirectiveDiagnostics(child, current.Index(index), sequencesAllowed); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
@@ -249,3 +269,72 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func disabledRootImportDiagnostics(node *document.Node) []Diagnostic {
|
||||
if node == nil || node.Kind != document.Mapping {
|
||||
return nil
|
||||
}
|
||||
var rootDirective *document.Node
|
||||
var rootLocation document.Location
|
||||
var foundRootDirective bool
|
||||
for index := range node.Mapping {
|
||||
if node.Mapping[index].Key == directiveKey {
|
||||
rootDirective = node.Mapping[index].Value
|
||||
rootLocation = node.Mapping[index].KeyLocation
|
||||
foundRootDirective = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundRootDirective {
|
||||
if rootDirective == nil || rootDirective.Kind != document.Mapping {
|
||||
return disabledDirectiveDiagnostic(rootLocation)
|
||||
}
|
||||
for _, directive := range rootDirective.Mapping {
|
||||
if directive.Key == "imports" {
|
||||
return disabledDirectiveDiagnostic(rootLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if entry.Key == directiveKey {
|
||||
continue
|
||||
}
|
||||
if diagnostic := disabledNestedDirectiveDiagnostics(entry.Value, Path{}.Key(entry.Key)); len(diagnostic) != 0 {
|
||||
return diagnostic
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func disabledNestedDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := path.Key(entry.Key)
|
||||
if entry.Key == directiveKey {
|
||||
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
||||
return []Diagnostic{{
|
||||
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
||||
Message: "configuration directives require imports to be enabled", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if diagnostics := disabledNestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := disabledNestedDirectiveDiagnostics(child, path.Index(index)); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func disabledDirectiveDiagnostic(location document.Location) []Diagnostic {
|
||||
public := Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
||||
return []Diagnostic{{
|
||||
Code: CodeImportDisabled, Severity: SeverityError, Path: Keys(directiveKey),
|
||||
Message: "configuration directives require imports to be enabled", Primary: &public,
|
||||
}}
|
||||
}
|
||||
|
||||
353
internal/engine/directory_links_test.go
Normal file
353
internal/engine/directory_links_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSelectDirectoriesApplyHiddenAndFollowControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"direct.yaml": "value: direct\n",
|
||||
".private.yaml": "value: hidden\n",
|
||||
".private/nested.yaml": "value: hidden directory\n",
|
||||
"target/linked.yaml": "value: linked\n",
|
||||
"target/source.txt": "value: source\n",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink("target", filepath.Join(root, "visible-alias")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target/linked.yaml", filepath.Join(root, "linked-alias.txt")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target/source.txt", filepath.Join(root, "selected.yaml")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target", filepath.Join(root, ".hidden-alias")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: false
|
||||
hidden: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"direct.yaml", "target/linked.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("filtered paths = %#v, want %#v", got, want)
|
||||
}
|
||||
selection = selectDirectoryWithOptions(t, access, root, ` - dir: visible-alias
|
||||
follow: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible-alias/linked.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("explicit symlink root paths = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
hidden: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"direct.yaml", "selected.yaml", "target/linked.yaml", "visible-alias/linked.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("followed paths = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
hidden: true
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{
|
||||
".hidden-alias/linked.yaml", ".private.yaml", ".private/nested.yaml", "direct.yaml",
|
||||
"selected.yaml", "target/linked.yaml", "visible-alias/linked.yaml",
|
||||
}; !equalStrings(got, want) {
|
||||
t.Fatalf("hidden-enabled paths = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesRejectEscapingAndCyclicLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("escape", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "outside.yaml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("escape diagnostics = %#v, want import.outside_root", diagnostics)
|
||||
}
|
||||
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if len(selection.candidates) != 0 {
|
||||
t.Fatalf("follow:false selected an escaping symlink: %#v", selection.candidates)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
}{
|
||||
{name: "self"},
|
||||
{name: "ancestor"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if test.name == "self" {
|
||||
if err := os.Symlink(".", filepath.Join(root, "loop")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("..", filepath.Join(root, "nested", "up")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportCycle {
|
||||
t.Fatalf("cycle diagnostics = %#v, want import.cycle", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Message != "directory traversal cycle detected" {
|
||||
t.Fatalf("cycle diagnostic exposes unexpected detail: %q", diagnostics[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesRetainSeparateNoncyclicAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "target"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target", "item.yaml"), []byte("value: item\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, alias := range []string{"first", "second"} {
|
||||
if err := os.Symlink("target", filepath.Join(root, alias)); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
selection := selectDirectoryWithOptions(t, access, root, ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
want := []string{"first/item.yaml", "second/item.yaml", "target/item.yaml"}
|
||||
if got := selectedPathsRelativeTo(selection, root); !equalStrings(got, want) {
|
||||
t.Fatalf("alias candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesUseNativeReadLinkCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &linkAwareMapFS{
|
||||
files: fstest.MapFS{
|
||||
"real": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"real/item.yaml": &fstest.MapFile{Data: []byte("value: item\n")},
|
||||
"alias": &fstest.MapFile{Mode: fs.ModeSymlink, Data: []byte("real")},
|
||||
".hidden": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
".hidden/secret.yaml": &fstest.MapFile{Data: []byte("value: secret\n")},
|
||||
},
|
||||
links: map[string]string{"alias": "real"},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
selection := selectDirectoryWithOptions(t, access, ".", ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
hidden: false
|
||||
pattern: '*.yaml'
|
||||
`)
|
||||
if got, want := selectedPaths(selection), []string{"alias/item.yaml", "real/item.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("native link candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
cyclic := &linkAwareMapFS{
|
||||
files: fstest.MapFS{
|
||||
"loop": &fstest.MapFile{Mode: fs.ModeSymlink, Data: []byte(".")},
|
||||
},
|
||||
links: map[string]string{"loop": "."},
|
||||
}
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, newNativeAccess(cyclic, ".", &namespaceToken{}), ".", ` - dir: .
|
||||
recursive: true
|
||||
follow: true
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportCycle {
|
||||
t.Fatalf("native loop diagnostics = %#v, want import.cycle", diagnostics)
|
||||
}
|
||||
|
||||
plain := newNativeAccess(fstest.MapFS{"plain.yaml": &fstest.MapFile{Data: []byte("value: plain\n")}}, ".", &namespaceToken{})
|
||||
selection = selectDirectoryWithOptions(t, plain, ".", " - dir: .\n")
|
||||
if got, want := selectedPaths(selection), []string{"plain.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("plain native filesystem selection = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func selectDirectoryWithOptions(t *testing.T, access *filesystemAccess, base, declaration string) importSelection {
|
||||
t.Helper()
|
||||
selection, diagnostics := selectDirectoryWithDiagnostics(t, access, base, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("directory selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base, declaration string) (importSelection, []Diagnostic) {
|
||||
t.Helper()
|
||||
specs, diagnostics := parseImportSpecsYAML(t, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selectDirectoryImportCandidates(access, base, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
}
|
||||
|
||||
func selectedPaths(selection importSelection) []string {
|
||||
paths := make([]string, 0, len(selection.candidates))
|
||||
for _, candidate := range selection.candidates {
|
||||
paths = append(paths, candidate.path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func selectedPathsRelativeTo(selection importSelection, root string) []string {
|
||||
paths := make([]string, 0, len(selection.candidates))
|
||||
for _, candidate := range selection.candidates {
|
||||
relative, err := filepath.Rel(root, candidate.path)
|
||||
if err != nil {
|
||||
paths = append(paths, candidate.path)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, filepath.ToSlash(relative))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func equalStrings(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type linkAwareMapFS struct {
|
||||
files fstest.MapFS
|
||||
links map[string]string
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) Open(name string) (fs.File, error) {
|
||||
resolved, err := f.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.files.Open(resolved)
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) ReadLink(name string) (string, error) {
|
||||
target, ok := f.links[name]
|
||||
if !ok {
|
||||
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) Lstat(name string) (fs.FileInfo, error) {
|
||||
if _, ok := f.links[name]; ok {
|
||||
return linkMapFileInfo{name: path.Base(name)}, nil
|
||||
}
|
||||
return fs.Stat(f.files, name)
|
||||
}
|
||||
|
||||
func (f *linkAwareMapFS) resolve(name string) (string, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
resolved := ""
|
||||
seen := make(map[string]struct{})
|
||||
for _, segment := range strings.Split(name, "/") {
|
||||
candidate := path.Join(resolved, segment)
|
||||
if target, ok := f.links[candidate]; ok {
|
||||
if _, exists := seen[candidate]; exists {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
candidate = path.Clean(path.Join(path.Dir(candidate), target))
|
||||
}
|
||||
resolved = candidate
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
type linkMapFileInfo struct{ name string }
|
||||
|
||||
func (i linkMapFileInfo) Name() string { return i.name }
|
||||
func (linkMapFileInfo) Size() int64 { return 0 }
|
||||
func (linkMapFileInfo) Mode() fs.FileMode { return fs.ModeSymlink | 0o777 }
|
||||
func (linkMapFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (linkMapFileInfo) IsDir() bool { return false }
|
||||
func (linkMapFileInfo) Sys() any { return nil }
|
||||
371
internal/engine/directory_selection_test.go
Normal file
371
internal/engine/directory_selection_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := fstest.MapFS{
|
||||
"one": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"one/a.yaml": &fstest.MapFile{Data: []byte("a: 1\n")},
|
||||
"one/README": &fstest.MapFile{Data: []byte("readme\n")},
|
||||
"one/.hidden": &fstest.MapFile{Data: []byte("hidden\n")},
|
||||
"one/draft.draft.yaml": &fstest.MapFile{Data: []byte("draft\n")},
|
||||
"one/nested/deep.yaml": &fstest.MapFile{Data: []byte("deep: 1\n")},
|
||||
"two": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"two/a.yaml": &fstest.MapFile{Data: []byte("a: 2\n")},
|
||||
"three": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"three/extensionless": &fstest.MapFile{Data: []byte("plain\n")},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, `
|
||||
- dir: [one, one, missing, two]
|
||||
pattern: ['*.yaml', 'a.*', 'README']
|
||||
exclude: '*.draft.yaml'
|
||||
required: true
|
||||
`)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
accounting := &importAccounting{}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), accounting)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 3 {
|
||||
t.Fatalf("selection = %#v, want three unioned matches", selection)
|
||||
}
|
||||
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
|
||||
t.Fatalf("selected paths = %#v", selection.candidates)
|
||||
}
|
||||
if accounting.entries != 6 {
|
||||
t.Fatalf("entry visits = %d, want 6 entries across distinct roots", accounting.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for name, value := range map[string]string{
|
||||
"a.yaml": "a: 1\n",
|
||||
".private": "private\n",
|
||||
"README": "readme\n",
|
||||
"nested/deep": "deep\n",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - dir: .\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(selection.candidates) != 3 {
|
||||
t.Fatalf("selected files = %#v, want yaml, hidden, and extensionless files", selection.candidates)
|
||||
}
|
||||
for _, candidate := range selection.candidates {
|
||||
if filepath.Base(candidate.path) == "deep" {
|
||||
t.Fatalf("shallow search descended into a child directory: %#v", selection.candidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := fstest.MapFS{
|
||||
"available": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"available/match.yaml": &fstest.MapFile{Data: []byte("match: true\n")},
|
||||
"available/excluded.yaml": &fstest.MapFile{Data: []byte("excluded: true\n")},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
yaml string
|
||||
wantError bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "missing root alongside a match",
|
||||
yaml: " - dir: [missing, available]\n pattern: '*.yaml'\n exclude: excluded.yaml\n required: true\n",
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "all matches excluded",
|
||||
yaml: " - dir: available\n pattern: '*.yaml'\n exclude: '*.yaml'\n required: true\n",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "explicit empty patterns select nothing",
|
||||
yaml: " - dir: available\n pattern: []\n",
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "empty search roots",
|
||||
yaml: " - dir: []\n required: true\n",
|
||||
wantError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
specs, diagnostics := parseImportSpecsYAML(t, test.yaml)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
if test.wantError {
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportMissing {
|
||||
t.Fatalf("diagnostics = %#v, want required empty-union error", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || len(selection.candidates) != test.wantCount {
|
||||
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if test.wantCount == 1 && selection.candidates[0].path != "available/match.yaml" {
|
||||
t.Fatalf("selected path = %q, want available/match.yaml", selection.candidates[0].path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := fstest.MapFS{
|
||||
"file.yaml": &fstest.MapFile{Data: []byte("not a directory\n")},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - dir: file.yaml\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, diagnostics = selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid {
|
||||
t.Fatalf("invalid-root diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
denied := &statOnlyImportFilesystem{statErrors: map[string]error{"denied": fs.ErrPermission}}
|
||||
access = newNativeAccess(denied, ".", &namespaceToken{})
|
||||
specs, diagnostics = parseImportSpecsYAML(t, " - dir: denied\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics = resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, diagnostics = selectDirectoryImportCandidates(access, ".", specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid {
|
||||
t.Fatalf("inaccessible-root diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "welcome.yaml"), []byte("welcome: true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("welcome.yaml", filepath.Join(root, "welcome.draft.yaml")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - dir: .\n pattern: '*.yaml'\n exclude: '*.draft.yaml'\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
if len(diagnostics) != 0 || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "welcome.yaml" {
|
||||
t.Fatalf("alias filtering selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for name := range map[string]struct{}{
|
||||
"direct.yaml": {},
|
||||
"child/child.yaml": {},
|
||||
"child/grand/grand.yaml": {},
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte("value: true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
yaml string
|
||||
defaults importDefaults
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
name: "depth one includes direct files",
|
||||
yaml: " - dir: .\n recursive: true\n depth: 1\n pattern: '*.yaml'\n",
|
||||
wantPaths: []string{"direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "depth two includes one descendant level",
|
||||
yaml: " - dir: .\n recursive: true\n depth: 2\n pattern: '*.yaml'\n",
|
||||
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "zero depth is unlimited",
|
||||
yaml: " - dir: .\n recursive: true\n depth: 0\n pattern: '*.yaml'\n",
|
||||
wantPaths: []string{"child.yaml", "grand.yaml", "direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "recursion stays disabled by default",
|
||||
yaml: " - dir: .\n pattern: '*.yaml'\n",
|
||||
wantPaths: []string{"direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "explicit false overrides application recursion",
|
||||
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
|
||||
defaults: importDefaults{recursive: boolPointer(true)},
|
||||
wantPaths: []string{"direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "application default enables recursion for depth",
|
||||
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
|
||||
defaults: importDefaults{recursive: boolPointer(true)},
|
||||
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "large depth remains a valid unlimited traversal bound",
|
||||
yaml: " - dir: .\n recursive: true\n depth: 999999999999999999999999999999\n pattern: '*.yaml'\n",
|
||||
wantPaths: []string{"child.yaml", "grand.yaml", "direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "filename filter does not prune directories",
|
||||
yaml: " - dir: .\n recursive: true\n depth: 3\n pattern: grand.yaml\n",
|
||||
wantPaths: []string{"grand.yaml"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
specs, diagnostics := parseImportSpecsYAML(t, test.yaml)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], test.defaults)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
var got []string
|
||||
for _, candidate := range selection.candidates {
|
||||
got = append(got, filepath.Base(candidate.path))
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(test.wantPaths, ",") {
|
||||
t.Fatalf("selected basenames = %#v, want %#v", got, test.wantPaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"direct.yaml", "child/child.yaml", "child/grand/grand.yaml"} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte("value: true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - dir: .\n recursive: true\n pattern: []\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
accounting := &importAccounting{}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
|
||||
if len(diagnostics) != 0 || selection.outcome != importSelectionNone || len(selection.candidates) != 0 {
|
||||
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if accounting.entries != 5 {
|
||||
t.Fatalf("empty search visited %d entries, want all 5 within the recursive search", accounting.entries)
|
||||
}
|
||||
|
||||
limited := defaultImportLimits()
|
||||
limited.entries = 2
|
||||
_, diagnostics = selectDirectoryImportCandidates(access, root, specs[0], policy, limited, &importAccounting{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("directory limit diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPointer(value bool) *bool { return &value }
|
||||
32
internal/engine/directory_walk.go
Normal file
32
internal/engine/directory_walk.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
var errImportDirectoryEntryLimit = errors.New("import directory-entry visit limit reached")
|
||||
|
||||
// visitBoundedImportDirectory shares entry accounting across directory and glob searches.
|
||||
func visitBoundedImportDirectory(access *filesystemAccess, directory string, limit int, accounting *importAccounting, visit func(fs.DirEntry) error) error {
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
var visitError error
|
||||
err := access.readDirectory(directory, func(entry fs.DirEntry) bool {
|
||||
accounting.entries++
|
||||
if accounting.entries > limit {
|
||||
visitError = errImportDirectoryEntryLimit
|
||||
return false
|
||||
}
|
||||
if err := visit(entry); err != nil {
|
||||
visitError = err
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if visitError != nil {
|
||||
return visitError
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -76,20 +76,55 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
|
||||
// Intermediate input replacements do not permanently suppress defaults:
|
||||
// decoding applies them to the final input tree. Show that reconciliation
|
||||
// explicitly, including canonical scalar conversions, before domain changes.
|
||||
// explicitly before typed CLI contributions and domain resolution.
|
||||
decodedInput := r.preCLI
|
||||
decodedOrigins := r.preCLIOrigins
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
|
||||
decoded, _ := documentAtPathWithLookup(decodedInput, path, r.immutableLookup)
|
||||
if !configurationValuesEqual(replayed, decoded) {
|
||||
origin := cloneOrigin(decodedOrigins[path.String()])
|
||||
origin.Operation = ChangeDecoding
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(decodedInput, path, r.secrets, r.immutableLookup),
|
||||
Origin: origin,
|
||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||
})
|
||||
}
|
||||
|
||||
working = document.Clone(decodedInput)
|
||||
for _, application := range r.postDecodeApps {
|
||||
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
before = valueSnapshot(working, path, r.secrets)
|
||||
}
|
||||
applyHistoryApplication(&working, application, replayIndex)
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: after,
|
||||
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
|
||||
})
|
||||
}
|
||||
replayed, _ = documentAtPath(working, path)
|
||||
effectiveInput, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
|
||||
if !configurationValuesEqual(replayed, effectiveInput) {
|
||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||
origin.Operation = ChangeDecoding
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
|
||||
Origin: origin,
|
||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||
Reasons: []string{"applied the typed CLI assignment"},
|
||||
})
|
||||
}
|
||||
|
||||
working = document.Clone(r.input)
|
||||
for _, snapshot := range r.resolutionReplay {
|
||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||
|
||||
@@ -351,6 +351,10 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||
opts.Overlay = "p"
|
||||
}
|
||||
result, err := testLoad(File(main), opts)
|
||||
if cli && base == "replaced-text" {
|
||||
requireSecretLoadCode(t, err, CodeDecodeType)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -360,6 +364,18 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||
t.Fatal("missing explanation")
|
||||
}
|
||||
last := explanation.History[len(explanation.History)-1]
|
||||
if cli && path.String() == "/m" {
|
||||
if last.After != explanation.Effective || last.Origin.Layer != LayerCLI || last.Origin.Operation != "" {
|
||||
t.Fatalf("CLI assignment was not the final history event: %#v", explanation)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cli && path.String() == "/m/a" {
|
||||
if explanation.Effective.Present {
|
||||
t.Fatalf("CLI map override retained descendants discarded by source null: %#v", explanation)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
|
||||
t.Fatalf("history does not reconcile final value: %#v", explanation)
|
||||
}
|
||||
|
||||
247
internal/engine/filesystem_links.go
Normal file
247
internal/engine/filesystem_links.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
errImportDirectoryLinkCycle = errors.New("directory link cycle")
|
||||
errImportPathOutsideRoot = errors.New("resolved import path is outside the configuration root")
|
||||
)
|
||||
|
||||
// importEntryTarget describes a discovered entry without changing its lexical
|
||||
// candidate spelling. identity is resolved only to detect active traversal loops.
|
||||
type importEntryTarget struct {
|
||||
symlink bool
|
||||
dir bool
|
||||
identity string
|
||||
}
|
||||
|
||||
func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, follow bool) (importEntryTarget, error) {
|
||||
if a.native == nil {
|
||||
relative, err := a.relative(name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
// Root.Lstat cannot follow absolute links in intermediate components,
|
||||
// even when contained. Resolve parents first, leaving the final entry
|
||||
// unexpanded so its discovered-link follow policy still applies.
|
||||
if parent := filepath.Dir(relative); parent != "." {
|
||||
resolvedParent, err := resolveHostImportPath(a, parent)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
relative = filepath.Join(filepath.FromSlash(resolvedParent), filepath.Base(relative))
|
||||
}
|
||||
info, err := a.root.Lstat(relative)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
symlink := info.Mode()&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
if !symlink && !info.IsDir() {
|
||||
return importEntryTarget{}, nil
|
||||
}
|
||||
resolved, err := resolveHostImportPath(a, relative)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
resolvedPath := filepath.Join(a.rootPath, filepath.FromSlash(resolved))
|
||||
info, err = a.root.Stat(filepath.FromSlash(resolved))
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: resolvedPath}, nil
|
||||
}
|
||||
|
||||
linkFS, linksKnown := a.native.(fs.ReadLinkFS)
|
||||
if linksKnown {
|
||||
info, err := linkFS.Lstat(name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
symlink := info.Mode()&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
if !symlink && !info.IsDir() {
|
||||
return importEntryTarget{}, nil
|
||||
}
|
||||
resolved, err := resolveNativeImportPath(a, linkFS, name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
if !a.contains(resolved) {
|
||||
return importEntryTarget{}, errImportPathOutsideRoot
|
||||
}
|
||||
info, err = fs.Stat(a.native, resolved)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: resolved}, nil
|
||||
}
|
||||
|
||||
symlink := entry != nil && entry.Type()&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
if entry != nil && !symlink {
|
||||
mode := entry.Type()
|
||||
if mode == 0 {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
mode = info.Mode()
|
||||
symlink = mode&fs.ModeSymlink != 0
|
||||
if symlink && !follow {
|
||||
return importEntryTarget{symlink: true}, nil
|
||||
}
|
||||
}
|
||||
if !symlink && !mode.IsDir() {
|
||||
return importEntryTarget{}, nil
|
||||
}
|
||||
if !symlink && mode.IsDir() {
|
||||
return importEntryTarget{dir: true, identity: name}, nil
|
||||
}
|
||||
}
|
||||
info, err := fs.Stat(a.native, name)
|
||||
if err != nil {
|
||||
return importEntryTarget{}, err
|
||||
}
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
||||
}
|
||||
|
||||
// A link stays active until its target has resolved, not until the entire
|
||||
// original pathname has resolved. Remaining segments may reuse a finished link.
|
||||
type importLinkFrame struct {
|
||||
pending []string
|
||||
link string
|
||||
}
|
||||
|
||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}}
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
for len(frames) > 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if len(frame.pending) == 0 {
|
||||
delete(activeLinks, frame.link)
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
segment := frame.pending[0]
|
||||
frame.pending = frame.pending[1:]
|
||||
candidate := path.Join(resolved, segment)
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
info, err := access.root.Lstat(filepath.FromSlash(candidate))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := access.root.Readlink(filepath.FromSlash(candidate))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var targetPath string
|
||||
if filepath.IsAbs(target) {
|
||||
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
|
||||
if err != nil {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath = path.Clean(filepath.ToSlash(relative))
|
||||
} else {
|
||||
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
|
||||
}
|
||||
if !withinPortableRoot(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func withinPortableRoot(name string) bool {
|
||||
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
|
||||
}
|
||||
|
||||
func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS, name string) (string, error) {
|
||||
if !fs.ValidPath(name) || !access.contains(name) {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
for len(frames) > 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if len(frame.pending) == 0 {
|
||||
delete(activeLinks, frame.link)
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
segment := frame.pending[0]
|
||||
frame.pending = frame.pending[1:]
|
||||
candidate := path.Join(resolved, segment)
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
// Ancestors of a multi-component root are resolution prefixes, not
|
||||
// selected targets. Complete paths and link targets remain contained.
|
||||
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
info, err := filesystem.Lstat(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := filesystem.ReadLink(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if target == "" || strings.HasPrefix(target, "/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
|
||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func isImportLinkCycle(err error) bool {
|
||||
return errors.Is(err, errImportDirectoryLinkCycle)
|
||||
}
|
||||
47
internal/engine/filesystem_links_test.go
Normal file
47
internal/engine/filesystem_links_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, target := range map[string]string{"parts/up": "..", "self": "self", "first": "second", "second": "first"} {
|
||||
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
host, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer host.close()
|
||||
filesystem := os.DirFS(root)
|
||||
links, ok := filesystem.(fs.ReadLinkFS)
|
||||
if !ok {
|
||||
t.Fatal("native directory filesystem does not expose link inspection")
|
||||
}
|
||||
native := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
for name, resolve := range map[string]func(string) (string, error){
|
||||
"host": func(name string) (string, error) { return resolveHostImportPath(host, name) },
|
||||
"native": func(name string) (string, error) { return resolveNativeImportPath(native, links, name) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got, err := resolve("parts/up/parts/up/parts"); err != nil || got != "parts" {
|
||||
t.Errorf("finite link reuse = %q, %v", got, err)
|
||||
}
|
||||
for _, cycle := range []string{"self", "first"} {
|
||||
if _, err := resolve(cycle); !errors.Is(err, errImportDirectoryLinkCycle) {
|
||||
t.Errorf("%s cycle = %v", cycle, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
51
internal/engine/final_identity.go
Normal file
51
internal/engine/final_identity.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
func validateFinalSequenceIdentities(root *document.Node, policy sequencePolicy) []Diagnostic {
|
||||
if root == nil || policy.selectorTrie == nil {
|
||||
return nil
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
var visit func(*document.Node, Path, []*sequenceSelectorTrie)
|
||||
visit = func(node *document.Node, path Path, states []*sequenceSelectorTrie) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == document.Sequence {
|
||||
var fields []string
|
||||
for _, state := range states {
|
||||
if state.terminal {
|
||||
fields = state.fields
|
||||
break
|
||||
}
|
||||
}
|
||||
if fields != nil {
|
||||
_, issues := validateNormalizedSequenceIdentityFrom(node, path, fields, sequenceIdentityDiagnosticCodes{
|
||||
invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict,
|
||||
}, 0, sequencePayloadRecord)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
}
|
||||
return
|
||||
}
|
||||
if node.Kind != document.Mapping {
|
||||
return
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
next := make([]*sequenceSelectorTrie, 0, len(states)*2)
|
||||
for _, state := range states {
|
||||
if literal := state.literals[entry.Key]; literal != nil {
|
||||
next = append(next, literal)
|
||||
}
|
||||
if state.wildcard != nil {
|
||||
next = append(next, state.wildcard)
|
||||
}
|
||||
}
|
||||
if len(next) != 0 {
|
||||
visit(entry.Value, path.Key(entry.Key), next)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root, Path{}, []*sequenceSelectorTrie{policy.selectorTrie})
|
||||
return canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
@@ -5,130 +5,223 @@ import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const globReadBatch = 128
|
||||
|
||||
type globCandidate struct {
|
||||
absolute string
|
||||
relative string
|
||||
}
|
||||
|
||||
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
|
||||
location := declaration.location
|
||||
segments, valid := validGlobPattern(declaration.glob)
|
||||
if !valid {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
|
||||
// selectGlobImportCandidates expands path components while retaining the
|
||||
// lexical spelling used for filtering and later source admission.
|
||||
func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting) (importSelection, []Diagnostic) {
|
||||
site := spec.fieldSites["glob"]
|
||||
segments := spec.globSegments
|
||||
if len(segments) == 0 {
|
||||
var valid bool
|
||||
segments, valid = validGlobPattern(spec.glob)
|
||||
if !valid {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []globCandidate{{absolute: basePath}}
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
rootPath := normalizedImportCandidatePath(access, basePath)
|
||||
candidates := []selectedImportCandidate{{path: rootPath, accessPath: basePath, site: site}}
|
||||
for index, segment := range segments {
|
||||
last := index == len(segments)-1
|
||||
literal, patterned := globLiteralSegment(segment)
|
||||
var next []globCandidate
|
||||
for _, candidate := range candidates {
|
||||
var names []string
|
||||
if patterned {
|
||||
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
|
||||
// nextByPath is the set union of candidates reached from each matching
|
||||
// parent at this segment; final exclusions below are set difference.
|
||||
nextByPath := make(map[string]selectedImportCandidate)
|
||||
for _, parent := range candidates {
|
||||
if !patterned {
|
||||
childPath := access.join(parent.path, literal)
|
||||
childAccessPath := access.join(parent.accessPath, literal)
|
||||
candidate, missing, diagnostics := explicitGlobCandidate(access, basePath, childPath, childAccessPath, site, last)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
return importSelection{}, diagnostics
|
||||
}
|
||||
names = matched
|
||||
} else {
|
||||
names = []string{literal}
|
||||
if !missing {
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, name := range names {
|
||||
absolute := s.access.join(candidate.absolute, name)
|
||||
relative := path.Join(candidate.relative, name)
|
||||
if !s.access.contains(absolute) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
||||
}
|
||||
canonical, err := s.access.canonical(absolute)
|
||||
|
||||
entries, missing, diagnostics := readGlobEntries(access, parent.accessPath, site.location, limits, accounting)
|
||||
if len(diagnostics) != 0 {
|
||||
return importSelection{}, diagnostics
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := path.Match(segment, entry.Name())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
||||
continue
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||
}
|
||||
if !s.access.contains(canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
||||
}
|
||||
if last {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
if s.accounting.sources+len(next) > s.limits.sources {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
||||
}
|
||||
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
info, err := s.access.stat(canonical)
|
||||
accessPath := access.join(parent.accessPath, entry.Name())
|
||||
lexicalPath := access.join(parent.path, entry.Name())
|
||||
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
||||
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
|
||||
}
|
||||
if info.IsDir() {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
if target.symlink && !policy.follow {
|
||||
continue
|
||||
}
|
||||
if !last && !target.dir {
|
||||
continue
|
||||
}
|
||||
physicalPath := target.identity
|
||||
// Native source identity and relative imports use the namespace
|
||||
// spelling; resolved link identities belong only to discovery.
|
||||
if access.native != nil {
|
||||
physicalPath = accessPath
|
||||
}
|
||||
if physicalPath == "" {
|
||||
physicalPath, err = access.canonical(accessPath)
|
||||
if err != nil {
|
||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
|
||||
}
|
||||
}
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
|
||||
}
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
}
|
||||
candidates = next
|
||||
candidates = candidatesFromMap(nextByPath)
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
matches := make([]string, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
matches[index] = candidate.relative
|
||||
selected := make([]selectedImportCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
missing, _, diagnostic := validateNamedImportTarget(access, candidate, false, false)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if missing || excludedBasename(path.Base(candidate.path), policy.exclude) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, candidate)
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches, nil
|
||||
if policy.required && len(selected) == 0 {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportMissing, "required import glob selected no files")}
|
||||
}
|
||||
// Source allowance is charged by source admission, after this filtered set
|
||||
// reaches the traversal; that preserves repeated-source diagnostics first.
|
||||
selection := importSelection{candidates: selected}
|
||||
if len(selected) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
||||
canonical, err := s.access.canonical(directory)
|
||||
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
||||
if !access.contains(candidatePath) || !access.contains(accessPath) {
|
||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, nil, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
return selectedImportCandidate{}, true, nil
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||
return selectedImportCandidate{}, false, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
|
||||
}
|
||||
if !s.access.contains(canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||
canonical, err := access.canonical(accessPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return selectedImportCandidate{}, true, nil
|
||||
}
|
||||
return selectedImportCandidate{}, false, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
|
||||
}
|
||||
if _, err := s.access.relative(canonical); err != nil {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||
if !access.contains(canonical) {
|
||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal resolves outside the configuration root")}
|
||||
}
|
||||
var matches []string
|
||||
var problem *Diagnostic
|
||||
err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
|
||||
s.accounting.entries++
|
||||
if s.accounting.entries > s.limits.entries {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)
|
||||
problem = &value
|
||||
return false
|
||||
if !last {
|
||||
if !target.dir {
|
||||
return selectedImportCandidate{}, true, nil
|
||||
}
|
||||
matched, matchErr := path.Match(pattern, entry.Name())
|
||||
if matchErr != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
|
||||
problem = &value
|
||||
return false
|
||||
}
|
||||
return selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, candidatePath), accessPath: canonical,
|
||||
label: relativeImportLabel(access, basePath, candidatePath), site: site,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func readGlobEntries(access *filesystemAccess, directory string, location Location, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, []Diagnostic) {
|
||||
target, err := access.importEntryTarget(directory, nil, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if matched {
|
||||
matches = append(matches, entry.Name())
|
||||
return nil, false, []Diagnostic{globCandidateDiagnostic(err, location, "glob directory could not be inspected")}
|
||||
}
|
||||
canonical, err := access.canonical(directory)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return true
|
||||
return nil, false, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||
}
|
||||
if !access.contains(canonical) {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||
}
|
||||
if _, err := access.relative(canonical); err != nil {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||
}
|
||||
readPath := target.identity
|
||||
if readPath == "" {
|
||||
readPath = canonical
|
||||
}
|
||||
entries := make([]fs.DirEntry, 0)
|
||||
err = visitBoundedImportDirectory(access, readPath, limits.entries, accounting, func(entry fs.DirEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})
|
||||
if problem != nil {
|
||||
return nil, []Diagnostic{*problem}
|
||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
|
||||
return nil, false, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches, nil
|
||||
sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() })
|
||||
return entries, false, nil
|
||||
}
|
||||
|
||||
func globCandidateDiagnostic(err error, location Location, message string) Diagnostic {
|
||||
if errors.Is(err, errImportDirectoryLinkCycle) {
|
||||
return importDiagnostic(CodeImportCycle, "directory traversal cycle detected", &location)
|
||||
}
|
||||
if errors.Is(err, errImportPathOutsideRoot) {
|
||||
return importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)
|
||||
}
|
||||
return globFilesystemDiagnostic(err, message, location)
|
||||
}
|
||||
|
||||
func candidatesFromMap(candidates map[string]selectedImportCandidate) []selectedImportCandidate {
|
||||
paths := make([]string, 0, len(candidates))
|
||||
for candidatePath := range candidates {
|
||||
paths = append(paths, candidatePath)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
result := make([]selectedImportCandidate, 0, len(paths))
|
||||
for _, candidatePath := range paths {
|
||||
result = append(result, candidates[candidatePath])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validGlobPattern(pattern string) ([]string, bool) {
|
||||
|
||||
203
internal/engine/glob_selection_test.go
Normal file
203
internal/engine/glob_selection_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestSelectGlobFiltersOnlyWildcardDiscoveredEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
".hidden/secret.yaml": "value: hidden\n",
|
||||
"visible/item.yaml": "value: visible\n",
|
||||
"visible/source.txt": "value: source\n",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for alias, target := range map[string]string{
|
||||
"visible-alias": "visible",
|
||||
"selected.yaml": "visible/source.txt",
|
||||
"unselected.txt": "visible/item.yaml",
|
||||
".secret.yaml": "visible/source.txt",
|
||||
} {
|
||||
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(alias))); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
selection := selectGlobForTest(t, access, root, ` - glob: '*/*.yaml'
|
||||
follow: false
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible/item.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("filtered glob candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: '.hidden/*.yaml'
|
||||
follow: false
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{".hidden/secret.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("literal hidden component candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: 'visible-alias/*.yaml'
|
||||
follow: false
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible-alias/item.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("literal symlink component candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: '*.yaml'
|
||||
follow: false
|
||||
hidden: true
|
||||
`)
|
||||
if len(selection.candidates) != 0 {
|
||||
t.Fatalf("follow:false selected a discovered file symlink: %#v", selection.candidates)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: '*.yaml'
|
||||
follow: true
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"selected.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("filename aliases were not filtered by candidate spelling: %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &observedGlobFilesystem{files: fstest.MapFS{
|
||||
"parts": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"parts/keep.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
"parts/skip-one.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
"parts/skip-two.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
"parts/skip-three.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
}}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, ` - glob: 'parts/*.yaml'
|
||||
exclude: 'skip-*.yaml'
|
||||
`)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
limits := defaultImportLimits()
|
||||
limits.sources = 2
|
||||
accounting := &importAccounting{sources: 1}
|
||||
selection, diagnostics := selectGlobImportCandidates(access, ".", specs[0], policy, limits, accounting)
|
||||
if len(diagnostics) != 0 || !equalStrings(selectedPaths(selection), []string{"parts/keep.yaml"}) {
|
||||
t.Fatalf("post-exclusion selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if len(filesystem.opened) != 1 || filesystem.opened[0] != "parts" {
|
||||
t.Fatalf("glob opened paths = %#v, want only the parts directory", filesystem.opened)
|
||||
}
|
||||
if accounting.sources+len(selection.candidates) > limits.sources {
|
||||
t.Fatalf("final candidate set exceeds the private source limit: accounting=%#v selection=%#v", accounting, selection.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGlobRejectsEscapingWildcardLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "file.yaml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
_, diagnostics := selectGlobForTestWithDiagnostics(t, access, root, ` - glob: '*/file.yaml'
|
||||
follow: true
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("escaping glob diagnostics = %#v, want import.outside_root", diagnostics)
|
||||
}
|
||||
selection := selectGlobForTest(t, access, root, ` - glob: '*/file.yaml'
|
||||
follow: false
|
||||
`)
|
||||
if len(selection.candidates) != 0 {
|
||||
t.Fatalf("follow:false selected an escaping alias: %#v", selection.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGlobPreservesMatchedDirectoryValidityBeforeExclusion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := fstest.MapFS{
|
||||
"parts": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"parts/matched.yaml": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"parts/matched.yaml/x": &fstest.MapFile{Data: []byte("value: x\n")},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
_, diagnostics := selectGlobForTestWithDiagnostics(t, access, ".", ` - glob: 'parts/*.yaml'
|
||||
exclude: '*.yaml'
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
|
||||
t.Fatalf("excluded directory diagnostics = %#v, want source.not_regular", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func selectGlobForTest(t *testing.T, access *filesystemAccess, base, declaration string) importSelection {
|
||||
t.Helper()
|
||||
selection, diagnostics := selectGlobForTestWithDiagnostics(t, access, base, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("glob selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, base, declaration string) (importSelection, []Diagnostic) {
|
||||
t.Helper()
|
||||
specs, diagnostics := parseImportSpecsYAML(t, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selectGlobImportCandidates(access, base, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
}
|
||||
|
||||
type observedGlobFilesystem struct {
|
||||
files fstest.MapFS
|
||||
opened []string
|
||||
}
|
||||
|
||||
func (filesystem *observedGlobFilesystem) Open(name string) (fs.File, error) {
|
||||
filesystem.opened = append(filesystem.opened, name)
|
||||
return filesystem.files.Open(name)
|
||||
}
|
||||
|
||||
func (filesystem *observedGlobFilesystem) Stat(name string) (fs.FileInfo, error) {
|
||||
return fs.Stat(filesystem.files, name)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -106,7 +107,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, test.pattern, false)
|
||||
mainPath := writeGlobRoot(t, directory, test.pattern, test.code == CodeImportMissing)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||
})
|
||||
}
|
||||
@@ -115,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "empty/*.yml", false)
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || len(result.Sources()) != 1 {
|
||||
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
|
||||
@@ -139,7 +140,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(directory, "conf/matched.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "conf/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "conf/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourceNotRegular)
|
||||
})
|
||||
|
||||
@@ -168,6 +169,10 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Items["linked"] != "linked" {
|
||||
var loadError *LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("contained directory symlink = %#v, diagnostics %#v", result, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("contained directory symlink = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
@@ -179,7 +184,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
})
|
||||
}
|
||||
@@ -198,7 +203,7 @@ func TestOptionalGlobDoesNotSuppressDirectoryPermissionFailure(t *testing.T) {
|
||||
_ = opened.Close()
|
||||
t.Skip("filesystem does not enforce the unreadable-directory fixture")
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "locked/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "locked/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportPermission)
|
||||
}
|
||||
|
||||
@@ -207,7 +212,7 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
||||
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
|
||||
writeImportFile(t, directory, "many/"+name, "ignored\n")
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "many/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "many/*.yml", false)
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
limits.entries = 2
|
||||
@@ -263,11 +268,11 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) string {
|
||||
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
||||
t.Helper()
|
||||
optionalLine := ""
|
||||
if optional {
|
||||
optionalLine = "\n optional: true"
|
||||
requiredLine := ""
|
||||
if required {
|
||||
requiredLine = "\n required: true"
|
||||
}
|
||||
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+requiredLine+"\n")
|
||||
}
|
||||
|
||||
@@ -42,13 +42,15 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
|
||||
|
||||
// History applications borrow immutable policy-validated input trees. Metadata
|
||||
// records answer presence and origin queries; replay reconstructs value changes.
|
||||
// Replay reconciles with canonical decoding before domain resolution so both
|
||||
// contracts agree on the effective result without retaining a tree per field.
|
||||
// Replay reconciles with canonical decoding, applies supplied CLI contributions,
|
||||
// then records domain resolution without retaining a tree per field.
|
||||
type historyApplication struct {
|
||||
layer Layer
|
||||
path Path
|
||||
value *document.Node
|
||||
origin Origin
|
||||
layer Layer
|
||||
path Path
|
||||
value *document.Node
|
||||
origin Origin
|
||||
sequences []sequenceHistoryContribution
|
||||
sequencesByPath map[string]sequenceHistoryContributionIndex
|
||||
}
|
||||
|
||||
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
||||
@@ -59,33 +61,143 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
|
||||
for index, value := range values {
|
||||
cloned[index] = historyApplication{
|
||||
layer: value.layer, path: value.path.clone(), value: value.value,
|
||||
origin: cloneOrigin(value.origin),
|
||||
origin: cloneOrigin(value.origin), sequences: cloneSequenceHistoryContributions(value.sequences),
|
||||
}
|
||||
cloned[index].sequencesByPath = indexSequenceHistoryContributions(cloned[index].sequences)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// walkHistoryApplication visits a retained source tree with both its local
|
||||
// semantic path and its effective projected path. Sequence fragments keep
|
||||
// their original tree indexes, while the effective path uses the assigned
|
||||
// range recorded by strict composition.
|
||||
func walkHistoryApplication(application historyApplication, visit func(local, effective Path, node *document.Node)) {
|
||||
if application.value == nil {
|
||||
return
|
||||
}
|
||||
var walk func(*document.Node, Path, Path)
|
||||
walk = func(node *document.Node, local, effective Path) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
visit(local, appendDestination(application.path, effective), node)
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
walk(entry.Value, local.Key(entry.Key), effective.Key(entry.Key))
|
||||
}
|
||||
case document.Sequence:
|
||||
contribution, projected := sequenceHistoryAt(application.sequencesByPath, local)
|
||||
for index, child := range node.Sequence {
|
||||
effectiveIndex := index
|
||||
if projected {
|
||||
if contribution.mode == sequenceHistoryMapped {
|
||||
effectiveIndex = contribution.outputForInput(index)
|
||||
if effectiveIndex < 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
return Path{}, false
|
||||
}
|
||||
if contribution.mode == sequenceHistoryMapped {
|
||||
inputIndex := contribution.inputForOutput(index)
|
||||
if inputIndex < 0 {
|
||||
return Path{}, false
|
||||
}
|
||||
local = local.Index(inputIndex)
|
||||
continue
|
||||
}
|
||||
if index < contribution.offset || index >= contribution.offset+contribution.length {
|
||||
return Path{}, false
|
||||
}
|
||||
local = local.Index(index - contribution.offset)
|
||||
continue
|
||||
}
|
||||
local.segments = append(local.segments, segment)
|
||||
}
|
||||
return local, true
|
||||
}
|
||||
|
||||
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
|
||||
if !application.path.valid() || application.value == nil {
|
||||
return Path{}, false
|
||||
}
|
||||
if application.path.String() != "" {
|
||||
if pathsIntersect(application.path, query) {
|
||||
if !pathContains(application.path, query) {
|
||||
if pathContains(query, application.path) {
|
||||
return application.path.clone(), true
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
if len(query.segments) == 0 {
|
||||
return Path{}, true
|
||||
sequencePathMatched := false
|
||||
for _, contribution := range application.sequences {
|
||||
container := appendDestination(application.path, contribution.path)
|
||||
if !pathContains(container, query) && !pathContains(query, container) {
|
||||
continue
|
||||
}
|
||||
sequencePathMatched = true
|
||||
if pathContains(container, query) && len(query.segments) > len(container.segments) {
|
||||
local, ok := historyApplicationLocalPath(application, query)
|
||||
if contribution.mode == sequenceHistoryReplace {
|
||||
if _, present := documentAtPathWithLookup(application.value, local, lookup); !ok || !present {
|
||||
// A replacement also changes descendants missing from the
|
||||
// incoming list. Attribute their removal to its container.
|
||||
return container, true
|
||||
}
|
||||
}
|
||||
indexed, _ := sequenceHistoryAt(application.sequencesByPath, contribution.path)
|
||||
if indexed.mode == sequenceHistoryMapped && len(query.segments) > len(container.segments) {
|
||||
outputIndex, valid := schemaIndex(query.segments[len(container.segments)])
|
||||
_, replaced := indexed.replacedOutputs[outputIndex]
|
||||
if valid && replaced {
|
||||
if _, present := documentAtPathWithLookup(application.value, local, lookup); !ok || !present {
|
||||
return container.Index(outputIndex), true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return query.clone(), true
|
||||
}
|
||||
for length := 1; length <= len(query.segments); length++ {
|
||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||
if sequencePathMatched {
|
||||
return Path{}, false
|
||||
}
|
||||
if len(query.segments) == len(application.path.segments) || application.value.Kind != document.Mapping {
|
||||
return application.path.clone(), true
|
||||
}
|
||||
relative, _ := relativeHistoryPath(application.path, query)
|
||||
for length := 1; length <= len(relative.segments); length++ {
|
||||
prefix := Path{segments: clonePathSegments(relative.segments[:length], 0)}
|
||||
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
||||
if !present {
|
||||
return Path{}, false
|
||||
}
|
||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
||||
return prefix, true
|
||||
if length == len(relative.segments) || node.Kind != document.Mapping {
|
||||
return appendDestination(application.path, prefix), true
|
||||
}
|
||||
}
|
||||
return Path{}, false
|
||||
@@ -96,7 +208,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
|
||||
return cloneOrigin(application.origin)
|
||||
}
|
||||
origin := Origin{Layer: application.layer}
|
||||
relative, ok := relativeHistoryPath(application.path, operationPath)
|
||||
relative, ok := historyApplicationLocalPath(application, operationPath)
|
||||
if !ok {
|
||||
return origin
|
||||
}
|
||||
@@ -119,13 +231,17 @@ func pathsIntersect(left, right Path) bool {
|
||||
|
||||
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
|
||||
if application.path.String() == "" {
|
||||
mergeHistoryDocument(root, application.value, index)
|
||||
mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequencesByPath)
|
||||
return
|
||||
}
|
||||
mergeHistoryAtPath(root, application.path, application.value, index)
|
||||
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
|
||||
}
|
||||
|
||||
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]sequenceHistoryContributionIndex) {
|
||||
if incoming == nil {
|
||||
return
|
||||
}
|
||||
@@ -141,18 +257,120 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node, index
|
||||
index.append(*target, cloned)
|
||||
continue
|
||||
}
|
||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index)
|
||||
entryPath := path.Key(entry.Key)
|
||||
if contribution, found := sequenceHistoryAt(sequences, entryPath); found {
|
||||
applySequenceHistory(&(*target).Mapping[position].Value, entry.Value, contribution)
|
||||
continue
|
||||
}
|
||||
mergeHistoryDocumentAtPath(&(*target).Mapping[position].Value, entry.Value, entryPath, index, sequences)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
|
||||
func sequenceHistoryAt(contributions map[string]sequenceHistoryContributionIndex, path Path) (sequenceHistoryContributionIndex, bool) {
|
||||
contribution, found := contributions[path.String()]
|
||||
return contribution, found
|
||||
}
|
||||
|
||||
type sequenceHistoryContributionIndex struct {
|
||||
sequenceHistoryContribution
|
||||
projection sequenceItemProjectionIndex
|
||||
replacedOutputs map[int]struct{}
|
||||
}
|
||||
|
||||
func indexSequenceHistoryContributions(values []sequenceHistoryContribution) map[string]sequenceHistoryContributionIndex {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
index := make(map[string]sequenceHistoryContributionIndex, len(values))
|
||||
for _, value := range values {
|
||||
contribution := sequenceHistoryContributionIndex{sequenceHistoryContribution: value}
|
||||
if value.mode == sequenceHistoryMapped {
|
||||
contribution.projection = newSequenceItemProjectionIndex(value.items)
|
||||
contribution.replacedOutputs = make(map[int]struct{}, len(value.replacements))
|
||||
for _, replacement := range value.replacements {
|
||||
contribution.replacedOutputs[replacement.outputIndex] = struct{}{}
|
||||
}
|
||||
}
|
||||
index[value.path.String()] = contribution
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (contribution sequenceHistoryContributionIndex) outputForInput(inputIndex int) int {
|
||||
outputIndex, ok := contribution.projection.outputForInput(inputIndex)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
return outputIndex
|
||||
}
|
||||
|
||||
func (contribution sequenceHistoryContributionIndex) inputForOutput(outputIndex int) int {
|
||||
inputIndex, ok := contribution.projection.inputForOutput(outputIndex)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
return inputIndex
|
||||
}
|
||||
|
||||
func applySequenceHistory(target **document.Node, incoming *document.Node, contribution sequenceHistoryContributionIndex) {
|
||||
if incoming == nil || incoming.Kind != document.Sequence {
|
||||
return
|
||||
}
|
||||
if contribution.mode == sequenceHistoryReplace || *target == nil || (*target).Kind != document.Sequence {
|
||||
*target = document.Clone(incoming)
|
||||
return
|
||||
}
|
||||
if contribution.mode == sequenceHistoryMapped {
|
||||
for _, projection := range contribution.items {
|
||||
if projection.inputIndex < 0 || projection.inputIndex >= len(incoming.Sequence) || projection.outputIndex < 0 {
|
||||
continue
|
||||
}
|
||||
for len((*target).Sequence) <= projection.outputIndex {
|
||||
(*target).Sequence = append((*target).Sequence, nil)
|
||||
}
|
||||
(*target).Sequence[projection.outputIndex] = document.Clone(incoming.Sequence[projection.inputIndex])
|
||||
}
|
||||
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()
|
||||
cloned[index].items = append([]sequenceItemProjection(nil), value.items...)
|
||||
cloned[index].replacements = append([]sequenceItemReplacement(nil), value.replacements...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
|
||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||
mergeHistoryDocument(root, incoming, index)
|
||||
mergeHistoryDocumentAtPath(root, incoming, Path{}, index, sequences)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for segmentIndex, segment := range path.segments {
|
||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
||||
if segment.kind == pathSegmentIndex {
|
||||
position, ok := schemaIndex(segment)
|
||||
if current.Kind != document.Sequence || !ok || position >= len(current.Sequence) {
|
||||
return
|
||||
}
|
||||
if segmentIndex == len(path.segments)-1 {
|
||||
mergeHistoryDocumentAtPath(¤t.Sequence[position], incoming, Path{}, index, sequences)
|
||||
return
|
||||
}
|
||||
current = current.Sequence[position]
|
||||
continue
|
||||
}
|
||||
if current.Kind != document.Mapping {
|
||||
return
|
||||
}
|
||||
position := index.find(current, segment.key)
|
||||
@@ -160,7 +378,7 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
|
||||
if position < 0 {
|
||||
index.append(current, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
||||
} else {
|
||||
mergeHistoryDocument(¤t.Mapping[position].Value, incoming, index)
|
||||
mergeHistoryDocumentAtPath(¤t.Mapping[position].Value, incoming, Path{}, index, sequences)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
589
internal/engine/history_sequence_test.go
Normal file
589
internal/engine/history_sequence_test.go
Normal file
@@ -0,0 +1,589 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
||||
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"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 TestTierSequenceHistoryReplaysSelectedOperation(t *testing.T) {
|
||||
t.Parallel()
|
||||
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"},
|
||||
}})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode sequenceMode
|
||||
}{{"append", sequenceModeAppend}, {"upsert", sequenceModeUpsert}, {"replace", sequenceModeReplace}} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mode := test.mode
|
||||
lower, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "lower", "records: [{id: a, removed: old}, {id: b, value: old, removed: old}]\n"))
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
secondID := "d"
|
||||
if mode == sequenceModeUpsert {
|
||||
secondID = "b"
|
||||
}
|
||||
declarations := []sequenceDeclarationSpec{{destination: Keys("records"), modePresent: true, mode: mode}}
|
||||
higher, diagnostics := composeDocumentsWithCollectionPolicy(policy, lower.collectionPolicies, declarations,
|
||||
parseCompositionDocument(t, "first-peer", "records: [{id: c, value: first}]\n"),
|
||||
parseCompositionDocument(t, "second-peer", "records: [{id: "+secondID+", value: second}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
assembly := newAssemblyAccumulator(lower)
|
||||
if diagnostics := assembly.applyOverlayWithCollectionPolicy(higher.root, higher.origins, higher.collectionPolicies, higher.applications, LayerBase, policy, declarations); len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
if len(assembly.current.applications) != 3 {
|
||||
t.Fatalf("source applications = %d, want all three sources", len(assembly.current.applications))
|
||||
}
|
||||
if mode == sequenceModeUpsert {
|
||||
query := Keys("records").Index(1).Key("value")
|
||||
if _, relevant := applicationOperationPath(assembly.current.applications[1], query, nil); relevant {
|
||||
t.Fatal("first peer was attributed to another peer's upserted record")
|
||||
}
|
||||
origin := historyApplicationOrigin(assembly.current.applications[2], query, nil)
|
||||
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "second-peer" {
|
||||
t.Fatalf("upsert history source = %#v", origin)
|
||||
}
|
||||
}
|
||||
working := parseCompositionDocument(t, "initial", "{}\n")
|
||||
index := new(mappingIndex)
|
||||
for _, application := range assembly.current.applications {
|
||||
applyHistoryApplication(&working, application, index)
|
||||
}
|
||||
if !configurationValuesEqual(working, assembly.current.root) {
|
||||
t.Fatal("tier history differs from structural assembly before decoding")
|
||||
}
|
||||
overlay := parseCompositionDocument(t, "overlay", "records: [{id: c, value: overlay}]\n")
|
||||
declarations[0].mode = sequenceModeUpsert
|
||||
if diagnostics := assembly.applyOverlayWithCollectionPolicy(overlay, nil, collectionPolicyScope{}, nil, LayerOverlay, policy, declarations); len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
applyHistoryApplication(&working, assembly.current.applications[3], index)
|
||||
if !configurationValuesEqual(working, assembly.current.root) {
|
||||
t.Fatal("subsequent overlay history differs from structural assembly")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) {
|
||||
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("independent")}, identityFields: []string{"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)
|
||||
}
|
||||
|
||||
root = parseCompositionDocument(t, "base", "records: [{id: one, helper: {items: [{id: a}]}}]\n")
|
||||
application.value = parseCompositionDocument(t, "helper", "items: [{id: b}]\n")
|
||||
application.sequences = []sequenceHistoryContribution{{path: Keys("items"), mode: sequenceHistoryAppend, offset: 1, length: 1}}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
applyHistoryApplication(&root, application, new(mappingIndex))
|
||||
items, present := documentAtPath(root, Keys("records").Index(0).Key("helper").Key("items"))
|
||||
if !present || len(items.Sequence) != 2 {
|
||||
t.Fatalf("indexed helper lost its local sequence operation: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredUpsertExplanationFollowsStructuralOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
type config struct {
|
||||
Records []map[string]any `yaml:"records"`
|
||||
}
|
||||
loaded, err := testGroupedLoad(loadRequest[config]{
|
||||
main: loadInput{source: Bytes("main", []byte("configkit:\n sequences: [{destination: [records], key: id}]\nrecords: [{id: b, value: new-b}, {id: c, value: new-c}, {id: a, value: new-a}]\n"))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("records: [{id: a, value: old-a}, {id: b, value: old-b, removed: old}]\n"))}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, ok := loaded.Explain(Keys("records"))
|
||||
if !ok || len(explanation.History) == 0 {
|
||||
t.Fatal("missing collection history")
|
||||
}
|
||||
for _, change := range explanation.History {
|
||||
if change.Origin.Operation == ChangeDecoding {
|
||||
t.Fatal("structural upsert was misreported as a decoding reconciliation")
|
||||
}
|
||||
}
|
||||
if last := explanation.History[len(explanation.History)-1]; last.After.YAML != explanation.Effective.YAML || last.Origin.Layer != LayerBase {
|
||||
t.Fatalf("main contribution did not produce the effective collection: %#v", last)
|
||||
}
|
||||
removed, ok := loaded.Explain(Keys("records").Index(1).Key("removed"))
|
||||
if !ok || len(removed.History) == 0 || removed.Effective.Present {
|
||||
t.Fatalf("missing displaced field history: %#v, %v", removed, ok)
|
||||
}
|
||||
if last := removed.History[len(removed.History)-1]; last.After.Present || last.Origin.Layer != LayerBase || last.Origin.Operation == ChangeDecoding {
|
||||
t.Fatalf("upsert removal attribution = %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T) {
|
||||
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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, preCLI: 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 := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"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, preCLI: 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 TestMappedUpsertProjectsSensitiveReplacementEvidence(t *testing.T) {
|
||||
lowerRoot := parseCompositionDocument(t, "lower", `records:
|
||||
- id: first
|
||||
- id: middle
|
||||
- id: last
|
||||
token: MAPPED-SECRET-CANARY
|
||||
`)
|
||||
higherRoot := parseCompositionDocument(t, "higher", `records:
|
||||
- id: last
|
||||
token: visible
|
||||
- id: first
|
||||
- id: added
|
||||
`)
|
||||
lower, _ := documentAtPath(lowerRoot, Keys("records"))
|
||||
higher, _ := documentAtPath(higherRoot, Keys("records"))
|
||||
combined, diagnostics := combineSequence(
|
||||
lower, higher,
|
||||
sequenceCombinationPolicy(sequenceModeUpsert, "id"),
|
||||
sequenceCombinationContext{destination: Keys("records")},
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("combine diagnostics = %#v", diagnostics)
|
||||
}
|
||||
secretPath := Keys("records").Index(2).Key("token")
|
||||
secretNode, present := documentAtPath(lowerRoot, Keys("records").Index(2).Key("token"))
|
||||
if !present {
|
||||
t.Fatal("sensitive source token is missing")
|
||||
}
|
||||
secretNode.Sensitive = true
|
||||
application := historyApplication{
|
||||
layer: LayerOverlay, value: higherRoot,
|
||||
sequences: []sequenceHistoryContribution{{
|
||||
path: Keys("records"), mode: sequenceHistoryMapped,
|
||||
items: combined.higherItems, replacements: combined.replacements,
|
||||
}},
|
||||
}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
applications := []historyApplication{{value: lowerRoot}, application}
|
||||
replayed := &document.Node{Kind: document.Mapping}
|
||||
for _, current := range applications {
|
||||
applyHistoryApplication(&replayed, current, new(mappingIndex))
|
||||
}
|
||||
replayedRecords, _ := documentAtPath(replayed, Keys("records"))
|
||||
if !reflect.DeepEqual(replayedRecords.Sequence, combined.value.Sequence) {
|
||||
t.Fatalf("replayed value = %#v, want combination = %#v", replayedRecords.Sequence, combined.value.Sequence)
|
||||
}
|
||||
finalRoot := document.Clone(lowerRoot)
|
||||
finalRecords, _ := documentAtPath(finalRoot, Keys("records"))
|
||||
finalRecords.Sequence = document.Clone(combined.value).Sequence
|
||||
|
||||
var metadata Metadata
|
||||
finalizeMetadataPaths(&metadata, finalRoot, applications)
|
||||
inspection := &Inspection{
|
||||
metadata: metadata, baseline: &document.Node{Kind: document.Mapping},
|
||||
input: finalRoot, preCLI: finalRoot, final: finalRoot, applications: applications,
|
||||
}
|
||||
secretRule := func(candidate Path) bool { return reflect.DeepEqual(candidate, secretPath) }
|
||||
inspection.secrets = classifySecretPaths(nil, secretRule, metadata, finalRoot, applications)
|
||||
explanation, ok := inspection.Explain(secretPath)
|
||||
if !ok || !explanation.Effective.Redacted {
|
||||
t.Fatalf("mapped replacement explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := inspection.WriteExplanation(&rendered, secretPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(rendered.String(), "MAPPED-SECRET-CANARY") || !strings.Contains(rendered.String(), "[REDACTED]") {
|
||||
t.Fatalf("mapped replacement history leaked or was not redacted: %s", rendered.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequenceHistoryReplaysCombinationEvidence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operation sequenceMode
|
||||
lower string
|
||||
higher string
|
||||
}{
|
||||
{
|
||||
name: "append",
|
||||
operation: sequenceModeAppend,
|
||||
lower: "records: [{id: first}, {id: last}]\n",
|
||||
higher: "records: [{id: added}]\n",
|
||||
},
|
||||
{
|
||||
name: "upsert",
|
||||
operation: sequenceModeUpsert,
|
||||
lower: `records:
|
||||
- id: first
|
||||
label: same
|
||||
nested: {removed: lower}
|
||||
- id: middle
|
||||
- id: last
|
||||
nested: {removed: lower}
|
||||
`,
|
||||
higher: `records:
|
||||
- id: last
|
||||
nested: {kept: higher}
|
||||
- id: first
|
||||
label: same
|
||||
- id: added
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "replace",
|
||||
operation: sequenceModeReplace,
|
||||
lower: "records: [{id: discarded}]\n",
|
||||
higher: "records: [{id: replacement}]\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lowerRoot := parseCompositionDocument(t, "lower", test.lower)
|
||||
higherRoot := parseCompositionDocument(t, "higher", test.higher)
|
||||
lower, _ := documentAtPath(lowerRoot, Keys("records"))
|
||||
higher, _ := documentAtPath(higherRoot, Keys("records"))
|
||||
fields := []string(nil)
|
||||
if test.operation != sequenceModeReplace {
|
||||
fields = []string{"id"}
|
||||
}
|
||||
combined, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(test.operation, fields...), sequenceCombinationContext{destination: Keys("records")})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("combine diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
mode := sequenceHistoryMapped
|
||||
if test.operation == sequenceModeReplace {
|
||||
mode = sequenceHistoryReplace
|
||||
}
|
||||
length := 0
|
||||
if mode == sequenceHistoryReplace {
|
||||
length = len(combined.higherItems)
|
||||
}
|
||||
application := historyApplication{
|
||||
layer: LayerOverlay,
|
||||
value: higherRoot,
|
||||
sequences: []sequenceHistoryContribution{{
|
||||
path: Keys("records"), mode: mode, length: length,
|
||||
items: combined.higherItems, replacements: combined.replacements,
|
||||
}},
|
||||
}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
replayed := document.Clone(lowerRoot)
|
||||
applyHistoryApplication(&replayed, application, new(mappingIndex))
|
||||
got, present := documentAtPath(replayed, Keys("records"))
|
||||
if !present || !reflect.DeepEqual(got.Sequence, combined.value.Sequence) {
|
||||
t.Fatalf("replayed sequence = %#v, want combination = %#v", got, combined.value.Sequence)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappedSequenceHistoryReplacesWholeRecordsAndKeepsDisplacedEvidence(t *testing.T) {
|
||||
lowerRoot := parseCompositionDocument(t, "lower", `records:
|
||||
- id: first
|
||||
label: same
|
||||
- id: middle
|
||||
- id: last
|
||||
nested: {removed: LOWER-CANARY}
|
||||
`)
|
||||
higherRoot := parseCompositionDocument(t, "higher", `records:
|
||||
- id: last
|
||||
nested: {kept: higher}
|
||||
- id: first
|
||||
label: same
|
||||
- id: added
|
||||
`)
|
||||
lower, _ := documentAtPath(lowerRoot, Keys("records"))
|
||||
higher, _ := documentAtPath(higherRoot, Keys("records"))
|
||||
combined, diagnostics := combineSequence(
|
||||
lower, higher,
|
||||
sequenceCombinationPolicy(sequenceModeUpsert, "id"),
|
||||
sequenceCombinationContext{destination: Keys("records")},
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
application := historyApplication{
|
||||
layer: LayerOverlay, value: higherRoot,
|
||||
sequences: []sequenceHistoryContribution{{
|
||||
path: Keys("records"), mode: sequenceHistoryMapped,
|
||||
items: combined.higherItems, replacements: combined.replacements,
|
||||
}},
|
||||
}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
clonedApplications := cloneHistoryApplications([]historyApplication{application})
|
||||
clonedApplications[0].sequences[0].items[0].outputIndex = 99
|
||||
clonedApplications[0].sequences[0].replacements[0].outputIndex = 99
|
||||
if application.sequences[0].items[0].outputIndex == 99 || application.sequences[0].replacements[0].outputIndex == 99 {
|
||||
t.Fatal("cloned mapped history aliases its source projections")
|
||||
}
|
||||
|
||||
query := Keys("records").Index(2).Key("nested").Key("removed")
|
||||
operationPath, relevant := applicationOperationPath(application, query, nil)
|
||||
if !relevant || operationPath.String() != "/records/2" {
|
||||
t.Fatalf("removed descendant operation = %s, %v", operationPath.String(), relevant)
|
||||
}
|
||||
localPath, ok := historyApplicationLocalPath(application, Keys("records").Index(2).Key("nested").Key("kept"))
|
||||
if !ok || localPath.String() != "/records/0/nested/kept" {
|
||||
t.Fatalf("mapped effective path localizes to %s, %v", localPath.String(), ok)
|
||||
}
|
||||
origin := historyApplicationOrigin(application, operationPath, nil)
|
||||
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "higher" {
|
||||
t.Fatalf("replacement operation origin = %#v", origin)
|
||||
}
|
||||
var walkedMappedPath bool
|
||||
walkHistoryApplication(application, func(local, effective Path, _ *document.Node) {
|
||||
if local.String() == "/records/0/nested/kept" && effective.String() == "/records/2/nested/kept" {
|
||||
walkedMappedPath = true
|
||||
}
|
||||
})
|
||||
if !walkedMappedPath {
|
||||
t.Fatal("history walk did not project a noncontiguous incoming item")
|
||||
}
|
||||
|
||||
replayed := document.Clone(lowerRoot)
|
||||
applyHistoryApplication(&replayed, application, new(mappingIndex))
|
||||
sequence, _ := documentAtPath(replayed, Keys("records"))
|
||||
if _, present := documentAtPath(sequence.Sequence[2], Keys("nested", "removed")); present {
|
||||
t.Fatal("whole-record replacement retained an omitted descendant")
|
||||
}
|
||||
if value, present := documentAtPath(sequence.Sequence[2], Keys("nested", "kept")); !present || value.Text != "higher" {
|
||||
t.Fatalf("replacement descendant = %#v, %v", value, present)
|
||||
}
|
||||
if sequence.Sequence[1].Mapping[0].Value.Text != "middle" {
|
||||
t.Fatal("unchanged neighboring record moved or changed")
|
||||
}
|
||||
if value, present := documentAtPath(lowerRoot, Keys("records").Index(2).Key("nested").Key("removed")); !present || value.Text != "LOWER-CANARY" {
|
||||
t.Fatalf("displaced lower contribution was not retained as raw history: %#v, %v", value, present)
|
||||
}
|
||||
if got := application.sequences[0].replacements; !reflect.DeepEqual(got, combined.replacements) || len(got) != 2 {
|
||||
t.Fatalf("replacement evidence lost equal-valued or noncontiguous events: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParsePath(t *testing.T, value string) Path {
|
||||
t.Helper()
|
||||
path, err := ParsePath(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
260
internal/engine/import_evidence_test.go
Normal file
260
internal/engine/import_evidence_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
- path: missing.yml
|
||||
- path: []
|
||||
- dir: fragments
|
||||
exclude: '*.yml'
|
||||
- path: [partial-missing.yml, present.yml]
|
||||
- path: []
|
||||
required: true
|
||||
`)
|
||||
writeImportFile(t, directory, "fragments/ignored.yml", "ignored: true\n")
|
||||
writeImportFile(t, directory, "present.yml", "value: imported\n")
|
||||
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Value != "imported" {
|
||||
t.Fatalf("value = %q", result.Config.Value)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
wantPaths := []string{"/configkit/imports/0", "/configkit/imports/1", "/configkit/imports/2", "/configkit/imports/3"}
|
||||
wantSelectors := []importSelector{importSelectorDirectory, importSelectorPath, importSelectorPath, importSelectorDirectory}
|
||||
evidence := sources[0].importSelections
|
||||
if len(evidence) != len(wantPaths) {
|
||||
t.Fatalf("empty-selection evidence = %#v", evidence)
|
||||
}
|
||||
for index, selection := range evidence {
|
||||
if selection.path.String() != wantPaths[index] || selection.location.SourceID != sources[0].ID ||
|
||||
selection.selector != wantSelectors[index] || selection.outcome != importSelectionNone {
|
||||
t.Errorf("selection %d = %#v", index, selection)
|
||||
}
|
||||
}
|
||||
if len(sources[1].importSelections) != 0 {
|
||||
t.Fatalf("imported source unexpectedly owns declaration evidence: %#v", sources[1])
|
||||
}
|
||||
|
||||
sources[0].importSelections[0].path.segments[0].key = "changed"
|
||||
sources[0].importSelections[0].location.SourceID = "changed"
|
||||
if got := result.Sources()[0].importSelections[0]; got.path.String() != wantPaths[0] || got.location.SourceID != "source-1" {
|
||||
t.Fatalf("mutating a query snapshot changed the retained record: %#v", got)
|
||||
}
|
||||
other, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(other.Sources()[0].importSelections, result.Inspection.Sources()[0].importSelections) {
|
||||
t.Fatal("one load's source snapshot affected another load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedLoadRetainsOnlyCompletedEmptyImportSelections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: absent/*.yml
|
||||
- path: required-missing.yml
|
||||
required: true
|
||||
- dir: never-visited
|
||||
value: main
|
||||
`)
|
||||
_, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || len(sources[0].importSelections) != 1 {
|
||||
t.Fatalf("failed-load sources = %#v", sources)
|
||||
}
|
||||
selection := sources[0].importSelections[0]
|
||||
if selection.path.String() != "/configkit/imports/0" || selection.location.SourceID != sources[0].ID || selection.selector != importSelectorGlob || selection.outcome != importSelectionNone {
|
||||
t.Fatalf("retained selection = %#v", selection)
|
||||
}
|
||||
sources[0].importSelections[0].path.segments[0].key = "changed"
|
||||
if got := loadError.Sources()[0].importSelections[0].path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("mutating failed-load snapshot changed retained evidence: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSelectionEvidenceSurvivesCompositionUnderlaysAndResolutionEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Metadata map[string]string `yaml:"metadata"`
|
||||
}
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
Helper string `yaml:"helper"`
|
||||
Items []item `yaml:"items"`
|
||||
Overlay string `yaml:"overlay"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: helper.yml
|
||||
- path: item.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
- dir: absent-main
|
||||
value: main
|
||||
`)
|
||||
helperPath := writeImportFile(t, directory, "helper.yml", `configkit:
|
||||
imports:
|
||||
- glob: absent-helper/*.yml
|
||||
helper: value
|
||||
`)
|
||||
itemPath := writeImportFile(t, directory, "item.yml", `configkit:
|
||||
imports:
|
||||
- path: []
|
||||
- path: item-helper.yml
|
||||
destination: [metadata]
|
||||
id: selected
|
||||
`)
|
||||
itemHelperPath := writeImportFile(t, directory, "item-helper.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent-item-helper
|
||||
key: value
|
||||
`)
|
||||
underlayPath := writeImportFile(t, directory, "underlay.yml", `configkit:
|
||||
imports:
|
||||
- path: []
|
||||
value: underlay
|
||||
`)
|
||||
overlayPath := writeImportFile(t, directory, filepath.Join("overlays", "selected.yml"), "overlay: value\n")
|
||||
var resolutionSources map[string]SourceInfo
|
||||
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, testSecretBinding(), LayeredOptions[config]{
|
||||
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
resolutionSources = make(map[string]SourceInfo)
|
||||
for _, id := range []string{"source-1", "source-2", "source-3", "source-4", "source-5", "source-6"} {
|
||||
if source, ok := resolution.Source(id); ok {
|
||||
resolutionSources[id] = source
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
|
||||
Overlay: "selected", OverlayFilesystem: &FilesystemContext{BaseDir: directory, Root: directory},
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if loaded.Config.Value != "main" || loaded.Config.Overlay != "value" || len(loaded.Config.Items) != 1 || loaded.Config.Items[0].ID != "selected" {
|
||||
t.Fatalf("configuration = %#v", loaded.Config)
|
||||
}
|
||||
want := map[string]string{
|
||||
"source-1": "/configkit/imports/2",
|
||||
"source-2": "/configkit/imports/0",
|
||||
"source-3": "/configkit/imports/0",
|
||||
"source-4": "/configkit/imports/0",
|
||||
"source-5": "/configkit/imports/0",
|
||||
}
|
||||
sources := loaded.Inspection.Sources()
|
||||
if len(sources) != 6 {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
if sources[1].Path != canonicalTestPath(t, helperPath) || sources[2].Path != canonicalTestPath(t, itemPath) ||
|
||||
sources[3].Path != canonicalTestPath(t, itemHelperPath) || sources[4].Path != canonicalTestPath(t, underlayPath) {
|
||||
t.Fatalf("acquired source order = %#v", sources)
|
||||
}
|
||||
if sources[3].destinationUnresolved || sources[3].Destination.String() != "/items/0/metadata" {
|
||||
t.Fatalf("nested item helper destination = %#v", sources[3])
|
||||
}
|
||||
if sources[5].Path != canonicalTestPath(t, overlayPath) || sources[5].Layer != LayerOverlay {
|
||||
t.Fatalf("overlay source = %#v", sources[5])
|
||||
}
|
||||
for id, path := range want {
|
||||
var found *SourceInfo
|
||||
for index := range sources {
|
||||
if sources[index].ID == id {
|
||||
found = &sources[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil || len(found.importSelections) != 1 || found.importSelections[0].path.String() != path || found.importSelections[0].location.SourceID != id {
|
||||
t.Errorf("source %s evidence = %#v, want %s", id, found, path)
|
||||
}
|
||||
if callbackSource, ok := resolutionSources[id]; !ok || len(callbackSource.importSelections) != 1 || callbackSource.importSelections[0].path.String() != path {
|
||||
t.Errorf("resolution source %s evidence = %#v, want %s", id, callbackSource, path)
|
||||
}
|
||||
}
|
||||
if len(sources[5].importSelections) != 0 {
|
||||
t.Fatalf("overlay unexpectedly has import-selection evidence: %#v", sources[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedNestedItemLoadKeepsEvidenceWithUnresolvedDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `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
|
||||
destination: [metadata]
|
||||
`)
|
||||
writeImportFile(t, directory, "helper.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
- path: missing.yml
|
||||
required: true
|
||||
key: value
|
||||
`)
|
||||
_, err := testLoad[config](File(mainPath), Options[config]{Imports: true})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
for _, source := range loadError.Sources() {
|
||||
if source.Path != canonicalTestPath(t, filepath.Join(directory, "helper.yml")) {
|
||||
continue
|
||||
}
|
||||
if !source.destinationUnresolved || source.Destination.String() != "/items" || len(source.importSelections) != 1 ||
|
||||
source.importSelections[0].path.String() != "/configkit/imports/0" || source.importSelections[0].location.SourceID != source.ID {
|
||||
t.Fatalf("failed nested item source = %#v", source)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("helper source missing from failed-load snapshot: %#v", loadError.Sources())
|
||||
}
|
||||
325
internal/engine/import_graph.go
Normal file
325
internal/engine/import_graph.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type importNodeKey struct {
|
||||
namespaceKind string
|
||||
namespaceAnchor string
|
||||
resourceKind string
|
||||
resourcePath string
|
||||
}
|
||||
|
||||
func (key importNodeKey) dependencyKey() dependencyKey {
|
||||
return dependencyKey{key.namespaceKind, key.namespaceAnchor, key.resourceKind, key.resourcePath}
|
||||
}
|
||||
|
||||
type importContributionRole string
|
||||
|
||||
const (
|
||||
importRoleMain importContributionRole = "main"
|
||||
importRoleMapping importContributionRole = "mapping-import"
|
||||
importRoleItem importContributionRole = "item-import"
|
||||
)
|
||||
|
||||
type importGraphNode struct {
|
||||
key importNodeKey
|
||||
canonical string
|
||||
sourceID string
|
||||
role importContributionRole
|
||||
}
|
||||
|
||||
type importInclusionEdge struct {
|
||||
importer importNodeKey
|
||||
imported importNodeKey
|
||||
location Location
|
||||
}
|
||||
|
||||
// importInclusionGraph retains source inclusion independently of mapping-scope
|
||||
// ownership. Scheduling edges point from each imported prerequisite to its
|
||||
// importer, matching the shared dependency scheduler's convention.
|
||||
type importInclusionGraph struct {
|
||||
nodes []importGraphNode
|
||||
edges []importInclusionEdge
|
||||
index map[importNodeKey]int
|
||||
}
|
||||
|
||||
func newImportInclusionGraph() importInclusionGraph {
|
||||
return importInclusionGraph{index: make(map[importNodeKey]int)}
|
||||
}
|
||||
|
||||
func (graph *importInclusionGraph) addNode(node importGraphNode) int {
|
||||
if graph.index == nil {
|
||||
graph.index = make(map[importNodeKey]int)
|
||||
}
|
||||
if index, exists := graph.index[node.key]; exists {
|
||||
return index
|
||||
}
|
||||
index := len(graph.nodes)
|
||||
graph.index[node.key] = index
|
||||
graph.nodes = append(graph.nodes, node)
|
||||
return index
|
||||
}
|
||||
|
||||
func (graph *importInclusionGraph) addEdge(importer, imported importNodeKey, location Location) int {
|
||||
index := len(graph.edges)
|
||||
graph.edges = append(graph.edges, importInclusionEdge{
|
||||
importer: importer, imported: imported, location: location,
|
||||
})
|
||||
return index
|
||||
}
|
||||
|
||||
func (graph importInclusionGraph) clone() importInclusionGraph {
|
||||
cloned := importInclusionGraph{
|
||||
nodes: append([]importGraphNode(nil), graph.nodes...),
|
||||
edges: make([]importInclusionEdge, len(graph.edges)),
|
||||
index: make(map[importNodeKey]int, len(graph.index)),
|
||||
}
|
||||
for index, edge := range graph.edges {
|
||||
cloned.edges[index] = importInclusionEdge{
|
||||
importer: edge.importer, imported: edge.imported, location: edge.location,
|
||||
}
|
||||
}
|
||||
for key, index := range graph.index {
|
||||
cloned.index[key] = index
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func mergeImportInclusionGraph(target *importInclusionGraph, source importInclusionGraph) {
|
||||
for _, node := range source.nodes {
|
||||
target.addNode(node)
|
||||
}
|
||||
for _, edge := range source.edges {
|
||||
target.addEdge(edge.importer, edge.imported, edge.location)
|
||||
}
|
||||
}
|
||||
|
||||
func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Location) (Diagnostic, bool) {
|
||||
keys := make([]dependencyKey, len(graph.nodes))
|
||||
for index, node := range graph.nodes {
|
||||
keys[index] = node.key.dependencyKey()
|
||||
}
|
||||
edges := make([]dependencyEdge, 0, len(graph.edges))
|
||||
for _, edge := range graph.edges {
|
||||
importer, importerOK := graph.index[edge.importer]
|
||||
imported, importedOK := graph.index[edge.imported]
|
||||
if !importerOK || !importedOK {
|
||||
continue
|
||||
}
|
||||
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
||||
}
|
||||
_, cycle := scheduleDependencies(keys, edges)
|
||||
if len(cycle) < 2 {
|
||||
return Diagnostic{}, false
|
||||
}
|
||||
|
||||
// Scheduler edges reverse inclusion edges. Reverse its closed cycle to
|
||||
// render the authored import route while keeping the canonical start node.
|
||||
route := make([]int, 0, len(cycle))
|
||||
route = append(route, cycle[0])
|
||||
for index := len(cycle) - 2; index > 0; index-- {
|
||||
route = append(route, cycle[index])
|
||||
}
|
||||
route = append(route, cycle[0])
|
||||
paths := make([]string, len(route))
|
||||
for index, node := range route {
|
||||
paths[index] = graph.nodes[node].canonical
|
||||
}
|
||||
|
||||
related := make([]Location, 0, len(route)-2)
|
||||
for index := 0; index+1 < len(route); index++ {
|
||||
importerKey := graph.nodes[route[index]].key
|
||||
importedKey := graph.nodes[route[index+1]].key
|
||||
edgeIndex := -1
|
||||
for candidate, edge := range graph.edges {
|
||||
if edge.importer == importerKey && edge.imported == importedKey {
|
||||
edgeIndex = candidate
|
||||
if candidate == closingEdge {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if edgeIndex == closingEdge {
|
||||
continue
|
||||
}
|
||||
if edgeIndex >= 0 {
|
||||
related = append(related, graph.edges[edgeIndex].location)
|
||||
}
|
||||
}
|
||||
return Diagnostic{
|
||||
Code: CodeImportCycle, Severity: SeverityError,
|
||||
Message: "import cycle detected: " + strings.Join(paths, " -> "),
|
||||
Primary: cloneLocation(&location), Related: related,
|
||||
}, true
|
||||
}
|
||||
|
||||
func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
|
||||
slots := make([]int, 0, len(scope.contributions))
|
||||
for index, contribution := range scope.contributions {
|
||||
if contribution.scopeOwner != scope.owner {
|
||||
continue
|
||||
}
|
||||
slots = append(slots, index)
|
||||
}
|
||||
scheduled := scope
|
||||
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, graph)
|
||||
return scheduled
|
||||
}
|
||||
|
||||
func scheduleAcquisitionGroup(scopes []acquisitionScope, graph importInclusionGraph) acquisitionScope {
|
||||
var contributions []acquiredContribution
|
||||
for _, scope := range scopes {
|
||||
contributions = append(contributions, scope.contributions...)
|
||||
}
|
||||
slots := make([]int, len(contributions))
|
||||
for index := range slots {
|
||||
slots[index] = index
|
||||
}
|
||||
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, graph)}
|
||||
}
|
||||
|
||||
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, graph importInclusionGraph) []acquiredContribution {
|
||||
if len(slots) < 2 {
|
||||
return append([]acquiredContribution(nil), contributions...)
|
||||
}
|
||||
positions := make(map[importNodeKey]int, len(slots))
|
||||
keys := make([]dependencyKey, len(slots))
|
||||
for position, slot := range slots {
|
||||
contribution := contributions[slot]
|
||||
positions[contribution.identity] = position
|
||||
keys[position] = contribution.identity.dependencyKey()
|
||||
}
|
||||
edges := make([]dependencyEdge, 0, len(graph.edges))
|
||||
for _, edge := range graph.edges {
|
||||
importer, importerOK := positions[edge.importer]
|
||||
imported, importedOK := positions[edge.imported]
|
||||
if importerOK && importedOK {
|
||||
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
||||
}
|
||||
}
|
||||
order, _ := scheduleDependencies(keys, edges)
|
||||
if len(order) != len(keys) {
|
||||
return append([]acquiredContribution(nil), contributions...)
|
||||
}
|
||||
scheduled := append([]acquiredContribution(nil), contributions...)
|
||||
for position, scheduledIndex := range order {
|
||||
scheduled[slots[position]] = contributions[slots[scheduledIndex]]
|
||||
}
|
||||
return scheduled
|
||||
}
|
||||
|
||||
type importNamespaceAnchors struct {
|
||||
kind string
|
||||
host string
|
||||
native string
|
||||
input string
|
||||
}
|
||||
|
||||
func explicitNamespaceAnchor(group, input int) string {
|
||||
return fmt.Sprintf("%020d/%020d", group, input)
|
||||
}
|
||||
|
||||
func reanchorImportKey(key importNodeKey, anchors importNamespaceAnchors) importNodeKey {
|
||||
if anchors.kind != "" {
|
||||
key.namespaceKind = anchors.kind
|
||||
}
|
||||
switch {
|
||||
case key.resourceKind == "synthetic":
|
||||
key.namespaceAnchor = anchors.input
|
||||
case key.namespaceKind == "native":
|
||||
key.namespaceAnchor = anchors.native
|
||||
default:
|
||||
key.namespaceAnchor = anchors.host
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func reanchorImportGraph(graph importInclusionGraph, anchors importNamespaceAnchors) (importInclusionGraph, map[importNodeKey]importNodeKey) {
|
||||
reanchored := newImportInclusionGraph()
|
||||
keys := make(map[importNodeKey]importNodeKey, len(graph.nodes))
|
||||
for _, node := range graph.nodes {
|
||||
old := node.key
|
||||
node.key = reanchorImportKey(old, anchors)
|
||||
keys[old] = node.key
|
||||
reanchored.addNode(node)
|
||||
}
|
||||
for _, edge := range graph.edges {
|
||||
reanchored.addEdge(keys[edge.importer], keys[edge.imported], edge.location)
|
||||
}
|
||||
return reanchored, keys
|
||||
}
|
||||
|
||||
func reanchorAcquisitionScope(scope *acquisitionScope, keys map[importNodeKey]importNodeKey, anchors importNamespaceAnchors) {
|
||||
if scope == nil {
|
||||
return
|
||||
}
|
||||
if key, exists := keys[scope.owner]; exists {
|
||||
scope.owner = key
|
||||
} else {
|
||||
scope.owner = reanchorImportKey(scope.owner, anchors)
|
||||
}
|
||||
for index := range scope.contributions {
|
||||
contribution := &scope.contributions[index]
|
||||
if key, exists := keys[contribution.identity]; exists {
|
||||
contribution.identity = key
|
||||
} else {
|
||||
contribution.identity = reanchorImportKey(contribution.identity, anchors)
|
||||
}
|
||||
if key, exists := keys[contribution.scopeOwner]; exists {
|
||||
contribution.scopeOwner = key
|
||||
} else {
|
||||
contribution.scopeOwner = reanchorImportKey(contribution.scopeOwner, anchors)
|
||||
}
|
||||
reanchorAcquisitionScope(contribution.pendingItem, keys, anchors)
|
||||
if contribution.nested != nil {
|
||||
for nestedIndex := range contribution.nested.acquisition {
|
||||
reanchorAcquisitionScope(&contribution.nested.acquisition[nestedIndex], keys, anchors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importKeyFor(access *filesystemAccess, canonical string, synthetic bool) importNodeKey {
|
||||
kind := "host"
|
||||
resourceKind := "file"
|
||||
resourcePath := filepath.ToSlash(canonical)
|
||||
if access.native != nil {
|
||||
kind = "native"
|
||||
}
|
||||
if synthetic {
|
||||
resourceKind = "synthetic"
|
||||
resourcePath = ""
|
||||
}
|
||||
return importNodeKey{
|
||||
namespaceKind: kind,
|
||||
// This traversal owns the main input position; grouped inputs receive
|
||||
// their explicit positions when their acquisition graphs are combined.
|
||||
namespaceAnchor: fmt.Sprintf("%020d/%020d", 0, 0),
|
||||
resourceKind: resourceKind, resourcePath: resourcePath,
|
||||
}
|
||||
}
|
||||
|
||||
func importKeyForMainSource(source *SourceInfo) importNodeKey {
|
||||
if source == nil {
|
||||
return importNodeKey{}
|
||||
}
|
||||
namespaceKind := "host"
|
||||
if source.Kind == SourceKindFilesystem {
|
||||
namespaceKind = "native"
|
||||
}
|
||||
synthetic := source.Kind != SourceKindFile && source.Kind != SourceKindFilesystem
|
||||
resourceKind := "file"
|
||||
resourcePath := filepath.ToSlash(source.Path)
|
||||
if synthetic {
|
||||
resourceKind = "synthetic"
|
||||
resourcePath = ""
|
||||
}
|
||||
return importNodeKey{
|
||||
namespaceKind: namespaceKind, namespaceAnchor: fmt.Sprintf("%020d/%020d", 0, 0),
|
||||
resourceKind: resourceKind, resourcePath: resourcePath,
|
||||
}
|
||||
}
|
||||
387
internal/engine/import_selection.go
Normal file
387
internal/engine/import_selection.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type importSelectionOutcome uint8
|
||||
|
||||
const (
|
||||
importSelectionNone importSelectionOutcome = iota
|
||||
importSelectionHasCandidates
|
||||
)
|
||||
|
||||
type selectedImportCandidate struct {
|
||||
path string
|
||||
accessPath string
|
||||
label string
|
||||
site importFieldSite
|
||||
}
|
||||
|
||||
// importSelection contains lexical candidates only; admission assigns source identity later.
|
||||
type importSelection struct {
|
||||
candidates []selectedImportCandidate
|
||||
outcome importSelectionOutcome
|
||||
}
|
||||
|
||||
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
|
||||
normalized := make([]selectedImportCandidate, 0, len(spec.paths))
|
||||
seen := make(map[string]struct{}, len(spec.paths))
|
||||
for index, relative := range spec.paths {
|
||||
accessPath := access.join(basePath, relative)
|
||||
candidatePath := accessPath
|
||||
if access.native == nil {
|
||||
candidatePath = filepath.ToSlash(filepath.Clean(accessPath))
|
||||
}
|
||||
if _, exists := seen[candidatePath]; exists {
|
||||
continue
|
||||
}
|
||||
seen[candidatePath] = struct{}{}
|
||||
var site importFieldSite
|
||||
if index < len(spec.selectorSites) {
|
||||
site = spec.selectorSites[index]
|
||||
} else {
|
||||
site = spec.fieldSites["path"]
|
||||
}
|
||||
normalized = append(normalized, selectedImportCandidate{path: candidatePath, accessPath: accessPath, label: relativeImportLabel(access, basePath, accessPath), site: site})
|
||||
}
|
||||
sort.Slice(normalized, func(left, right int) bool {
|
||||
return normalized[left].path < normalized[right].path
|
||||
})
|
||||
candidates := make([]selectedImportCandidate, 0, len(normalized))
|
||||
for _, candidate := range normalized {
|
||||
missing, _, diagnostic := validateNamedImportTarget(access, candidate, policy.required, false)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
if excludedBasename(path.Base(candidate.path), policy.exclude) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
selection := importSelection{candidates: candidates}
|
||||
if len(candidates) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func selectDirectoryImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting) (importSelection, []Diagnostic) {
|
||||
roots := make([]selectedImportCandidate, 0, len(spec.paths))
|
||||
seenRoots := make(map[string]struct{}, len(spec.paths))
|
||||
for index, relative := range spec.paths {
|
||||
accessPath := access.join(basePath, relative)
|
||||
candidatePath := normalizedImportCandidatePath(access, accessPath)
|
||||
if _, exists := seenRoots[candidatePath]; exists {
|
||||
continue
|
||||
}
|
||||
seenRoots[candidatePath] = struct{}{}
|
||||
site := spec.fieldSites["dir"]
|
||||
if index < len(spec.selectorSites) {
|
||||
site = spec.selectorSites[index]
|
||||
}
|
||||
roots = append(roots, selectedImportCandidate{path: candidatePath, accessPath: accessPath, site: site})
|
||||
}
|
||||
sort.Slice(roots, func(left, right int) bool { return roots[left].path < roots[right].path })
|
||||
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
discovered := make([]selectedImportCandidate, 0)
|
||||
seenCandidates := make(map[string]struct{})
|
||||
// Each root is traversed once. Filename patterns are tested together for
|
||||
// every discovered file, and seenCandidates forms their set union across
|
||||
// overlapping roots. Exclusions are applied once below as set difference.
|
||||
for _, root := range roots {
|
||||
missing, isDirectory, diagnostic := validateNamedImportTarget(access, root, false, true)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
if !isDirectory {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(root.site, CodeImportInvalid, "directory search root is not a directory")}
|
||||
}
|
||||
rootTarget, targetErr := access.importEntryTarget(root.accessPath, nil, true)
|
||||
if targetErr != nil {
|
||||
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(root.site, targetErr)}
|
||||
}
|
||||
if access.native == nil {
|
||||
root.accessPath = rootTarget.identity
|
||||
}
|
||||
entries, missing, diagnostic := importDirectoryEntries(access, root.accessPath, root.site, limits, accounting)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
stack := []importDirectoryFrame{{directory: root, depth: 0, entries: entries, identity: rootTarget.identity}}
|
||||
// Active-path cycle detection removes each directory when its frame is
|
||||
// popped, so a later non-active alias remains a separate traversal.
|
||||
activeDirectories := map[string]struct{}{rootTarget.identity: {}}
|
||||
for len(stack) != 0 {
|
||||
frameIndex := len(stack) - 1
|
||||
frame := &stack[frameIndex]
|
||||
if frame.next >= len(frame.entries) {
|
||||
delete(activeDirectories, frame.identity)
|
||||
stack = stack[:frameIndex]
|
||||
continue
|
||||
}
|
||||
entry := frame.entries[frame.next]
|
||||
frame.next++
|
||||
accessPath := access.join(frame.directory.accessPath, entry.Name())
|
||||
lexicalPath := access.join(frame.directory.path, entry.Name())
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
|
||||
}
|
||||
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
target, entryErr := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
||||
if entryErr != nil {
|
||||
if errors.Is(entryErr, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(frame.directory.site, entryErr)}
|
||||
}
|
||||
if target.symlink && !policy.follow {
|
||||
continue
|
||||
}
|
||||
if target.dir {
|
||||
if !directoryDepthAllows(policy, frame.depth) {
|
||||
continue
|
||||
}
|
||||
if _, exists := activeDirectories[target.identity]; exists {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")}
|
||||
}
|
||||
childMissing, childIsDirectory, childDiagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||
if childDiagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||
}
|
||||
if childMissing || !childIsDirectory {
|
||||
continue
|
||||
}
|
||||
childEntries, vanished, childDiagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
|
||||
if childDiagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||
}
|
||||
if !vanished {
|
||||
if access.native == nil {
|
||||
candidate.accessPath = target.identity
|
||||
}
|
||||
activeDirectories[target.identity] = struct{}{}
|
||||
stack = append(stack, importDirectoryFrame{directory: candidate, depth: frame.depth + 1, entries: childEntries, identity: target.identity})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if target.identity != "" && access.native == nil {
|
||||
candidate.accessPath = target.identity
|
||||
} else {
|
||||
canonical, canonicalErr := access.canonical(accessPath)
|
||||
if canonicalErr != nil {
|
||||
if errors.Is(canonicalErr, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(canonicalErr, "directory search file could not be canonicalized", frame.directory.site.location)}
|
||||
}
|
||||
candidate.accessPath = canonical
|
||||
}
|
||||
if !matchesImportFilename(entry.Name(), policy.pattern) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenCandidates[candidate.path]; exists {
|
||||
continue
|
||||
}
|
||||
seenCandidates[candidate.path] = struct{}{}
|
||||
discovered = append(discovered, candidate)
|
||||
}
|
||||
}
|
||||
sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path })
|
||||
selected := make([]selectedImportCandidate, 0, len(discovered))
|
||||
for _, candidate := range discovered {
|
||||
missing, isDirectory, diagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if missing || isDirectory || excludedBasename(path.Base(candidate.path), policy.exclude) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, candidate)
|
||||
}
|
||||
if policy.required && len(selected) == 0 {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
|
||||
}
|
||||
selection := importSelection{candidates: selected}
|
||||
if len(selected) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
type importDirectoryFrame struct {
|
||||
directory selectedImportCandidate
|
||||
depth int
|
||||
entries []fs.DirEntry
|
||||
next int
|
||||
identity string
|
||||
}
|
||||
|
||||
func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic {
|
||||
if isImportLinkCycle(err) {
|
||||
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")
|
||||
}
|
||||
if errors.Is(err, errImportPathOutsideRoot) {
|
||||
return importSelectionDiagnostic(site, CodeImportOutsideRoot, "import target resolves outside the configuration root")
|
||||
}
|
||||
return importSelectionDiagnostic(site, CodeImportInvalid, "directory entry could not be inspected")
|
||||
}
|
||||
|
||||
func importDirectoryEntries(access *filesystemAccess, directory string, site importFieldSite, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, *Diagnostic) {
|
||||
target, targetErr := access.importEntryTarget(directory, nil, true)
|
||||
if targetErr != nil {
|
||||
if errors.Is(targetErr, fs.ErrNotExist) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return nil, false, importDirectoryTargetDiagnostic(site, targetErr)
|
||||
}
|
||||
readPath := target.identity
|
||||
if readPath == "" {
|
||||
readPath = directory
|
||||
}
|
||||
entries := make([]fs.DirEntry, 0)
|
||||
err := visitBoundedImportDirectory(access, readPath, limits.entries, accounting, func(entry fs.DirEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||
return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, importSelectionDiagnostic(site, CodeImportInvalid, "directory search root could not be read")
|
||||
}
|
||||
sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() })
|
||||
return entries, false, nil
|
||||
}
|
||||
|
||||
func directoryDepthAllows(policy resolvedImportPolicy, currentDepth int) bool {
|
||||
if !policy.recursive {
|
||||
return false
|
||||
}
|
||||
childDepth := currentDepth + 1
|
||||
return !policy.depthSet || policy.depth == 0 || childDepth < policy.depth
|
||||
}
|
||||
|
||||
func normalizedImportCandidatePath(access *filesystemAccess, accessPath string) string {
|
||||
if access.native != nil {
|
||||
return accessPath
|
||||
}
|
||||
return filepath.ToSlash(filepath.Clean(accessPath))
|
||||
}
|
||||
|
||||
func relativeImportLabel(access *filesystemAccess, basePath, candidatePath string) string {
|
||||
if access.native != nil {
|
||||
if candidatePath == basePath {
|
||||
return "."
|
||||
}
|
||||
prefix := strings.TrimSuffix(basePath, "/") + "/"
|
||||
if basePath == "." {
|
||||
return candidatePath
|
||||
}
|
||||
if strings.HasPrefix(candidatePath, prefix) {
|
||||
return strings.TrimPrefix(candidatePath, prefix)
|
||||
}
|
||||
return candidatePath
|
||||
}
|
||||
label, err := filepath.Rel(basePath, candidatePath)
|
||||
if err == nil {
|
||||
return filepath.ToSlash(label)
|
||||
}
|
||||
return filepath.ToSlash(candidatePath)
|
||||
}
|
||||
|
||||
func matchesImportFilename(name string, patterns []string) bool {
|
||||
if patterns == nil {
|
||||
return true
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
matched, err := path.Match(pattern, name)
|
||||
if err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateNamedImportTarget(access *filesystemAccess, candidate selectedImportCandidate, required, allowDirectory bool) (bool, bool, *Diagnostic) {
|
||||
if !access.contains(candidate.accessPath) {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportOutsideRoot, "import target is outside the configuration root")
|
||||
}
|
||||
canonical, err := access.canonical(candidate.accessPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if required {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportMissing, "required import target does not exist")
|
||||
}
|
||||
return true, false, nil
|
||||
}
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target cannot be accessed")
|
||||
}
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target could not be canonicalized")
|
||||
}
|
||||
if !access.contains(canonical) {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportOutsideRoot, "import target resolves outside the configuration root")
|
||||
}
|
||||
info, err := access.stat(canonical)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if required {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportMissing, "required import target does not exist")
|
||||
}
|
||||
return true, false, nil
|
||||
}
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target cannot be accessed")
|
||||
}
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeImportInvalid, "import target could not be inspected")
|
||||
}
|
||||
if info.IsDir() && allowDirectory {
|
||||
return false, true, nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, false, importSelectionDiagnostic(candidate.site, CodeSourceNotRegular, "source is not a regular file")
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
func excludedBasename(name string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
matched, err := path.Match(pattern, name)
|
||||
if err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func importSelectionDiagnostic(site importFieldSite, code Code, message string) *Diagnostic {
|
||||
location := site.location
|
||||
return &Diagnostic{
|
||||
Code: code, Severity: SeverityError, Path: site.path.clone(),
|
||||
Message: message, Primary: &location,
|
||||
}
|
||||
}
|
||||
257
internal/engine/import_selection_test.go
Normal file
257
internal/engine/import_selection_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "present.yaml"), []byte("value: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: [missing.yaml, present.yaml]\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectNamedImportCandidates(access, root, specs[0], resolvedImportPolicy{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("optional partial selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" {
|
||||
t.Fatalf("partial selection = %#v", selection)
|
||||
}
|
||||
|
||||
requiredSpecs, diagnostics := parseImportSpecsYAML(t, " - path: [missing.yaml, present.yaml]\n required: true\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, diagnostics = selectNamedImportCandidates(access, root, requiredSpecs[0], resolvedImportPolicy{required: true})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportMissing {
|
||||
t.Fatalf("required partial selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
emptySpecs, diagnostics := parseImportSpecsYAML(t, " - path: []\n required: true\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true})
|
||||
if len(diagnostics) != 0 || empty.outcome != importSelectionNone || len(empty.candidates) != 0 {
|
||||
t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectNamedImportsChecksRequiredTargetsBeforeExclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: missing.yaml\n required: true\n exclude: '*.yaml'\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, diagnostics = selectNamedImportCandidates(access, root, specs[0], policy)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportMissing || diagnostics[0].Path.String() != "/configkit/imports/0/path" {
|
||||
t.Fatalf("required excluded missing target diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectNamedImportsDeduplicatesLexicalPathsButRetainsAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "real.yaml"), []byte("value: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("real.yaml", filepath.Join(root, "alias.yaml")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: [real.yaml, nested/../real.yaml, alias.yaml]\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectNamedImportCandidates(access, root, specs[0], resolvedImportPolicy{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(selection.candidates) != 2 {
|
||||
t.Fatalf("candidates = %#v, want two alias spellings", selection.candidates)
|
||||
}
|
||||
if filepath.Base(selection.candidates[0].path) != "alias.yaml" || filepath.Base(selection.candidates[1].path) != "real.yaml" {
|
||||
t.Fatalf("candidate order = %#v", selection.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &statOnlyImportFilesystem{files: map[string]bool{"secret.yaml": true, ".private.yaml": true}}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: secret.yaml\n required: true\n exclude: '*.yaml'\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy)
|
||||
if len(diagnostics) != 0 || selection.outcome != importSelectionNone {
|
||||
t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
|
||||
t.Fatalf("excluded target access = %d opens, %d stats", filesystem.openCalls, filesystem.statCalls)
|
||||
}
|
||||
|
||||
private, diagnostics := parseImportSpecsYAML(t, " - path: .private.yaml\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
hidden := false
|
||||
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], importDefaults{hidden: &hidden})
|
||||
if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
|
||||
t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics)
|
||||
}
|
||||
selection, diagnostics = selectNamedImportCandidates(access, ".", private[0], privatePolicy)
|
||||
if len(diagnostics) != 0 || len(selection.candidates) != 1 || selection.candidates[0].path != ".private.yaml" {
|
||||
t.Fatalf("explicit hidden path was filtered: %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if filesystem.openCalls != 0 {
|
||||
t.Fatalf("named selection opened content %d times", filesystem.openCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectNamedImportsChecksNormalizedPathsLexically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &statOnlyImportFilesystem{files: map[string]bool{"a.yaml": true, "z.yaml": true}}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: [z.yaml, a.yaml]\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], resolvedImportPolicy{})
|
||||
if len(diagnostics) != 0 || len(selection.candidates) != 2 {
|
||||
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if strings.Join(filesystem.statPaths, ",") != "a.yaml,a.yaml,z.yaml,z.yaml" {
|
||||
t.Fatalf("target stat order = %#v", filesystem.statPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectNamedImportsDoesNotTreatInvalidTargetsAsAbsence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "directory"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "outside.yaml")
|
||||
if err := os.WriteFile(outside, []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape.yaml")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
for _, test := range []struct {
|
||||
name, value string
|
||||
code Code
|
||||
}{
|
||||
{name: "nonregular target", value: "directory", code: CodeSourceNotRegular},
|
||||
{name: "outside symlink", value: "escape.yaml", code: CodeImportOutsideRoot},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: "+test.value+"\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, diagnostics = selectNamedImportCandidates(access, root, specs[0], resolvedImportPolicy{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
||||
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectNamedImportsDoesNotTreatPermissionErrorsAsAbsence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &statOnlyImportFilesystem{statErrors: map[string]error{"denied.yaml": fs.ErrPermission}}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - path: denied.yaml\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, diagnostics = selectNamedImportCandidates(access, ".", specs[0], resolvedImportPolicy{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid {
|
||||
t.Fatalf("optional permission failure diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
type statOnlyImportFilesystem struct {
|
||||
files map[string]bool
|
||||
statErrors map[string]error
|
||||
openCalls int
|
||||
statCalls int
|
||||
statPaths []string
|
||||
}
|
||||
|
||||
func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
|
||||
filesystem.openCalls++
|
||||
return nil, fs.ErrPermission
|
||||
}
|
||||
|
||||
func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) {
|
||||
filesystem.statCalls++
|
||||
filesystem.statPaths = append(filesystem.statPaths, name)
|
||||
if err := filesystem.statErrors[name]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !filesystem.files[name] {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return importSelectionFileInfo{name: pathBaseForTest(name)}, nil
|
||||
}
|
||||
|
||||
type importSelectionFileInfo struct{ name string }
|
||||
|
||||
func (info importSelectionFileInfo) Name() string { return info.name }
|
||||
func (importSelectionFileInfo) Size() int64 { return 1 }
|
||||
func (importSelectionFileInfo) Mode() fs.FileMode { return 0o600 }
|
||||
func (importSelectionFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (importSelectionFileInfo) IsDir() bool { return false }
|
||||
func (importSelectionFileInfo) Sys() any { return nil }
|
||||
func pathBaseForTest(name string) string { return strings.TrimPrefix(name, "./") }
|
||||
471
internal/engine/import_spec.go
Normal file
471
internal/engine/import_spec.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type importSelector uint8
|
||||
|
||||
const (
|
||||
importSelectorPath importSelector = iota + 1
|
||||
importSelectorDirectory
|
||||
importSelectorGlob
|
||||
)
|
||||
|
||||
type importFieldSite struct {
|
||||
path Path
|
||||
location Location
|
||||
}
|
||||
|
||||
// importDeclarationSpec preserves authored presence until discovery policy is resolved.
|
||||
type importDeclarationSpec struct {
|
||||
path Path
|
||||
location Location
|
||||
selector importSelector
|
||||
paths []string
|
||||
selectorSites []importFieldSite
|
||||
glob string
|
||||
globSegments []string
|
||||
required *bool
|
||||
recursive *bool
|
||||
follow *bool
|
||||
hidden *bool
|
||||
pattern []string
|
||||
patternSet bool
|
||||
exclude []string
|
||||
excludeSet bool
|
||||
depth int
|
||||
depthSet bool
|
||||
destination Path
|
||||
placement placementMode
|
||||
fieldSites map[string]importFieldSite
|
||||
}
|
||||
|
||||
// importDefaults are the engine's private copy of load-wide discovery defaults.
|
||||
type importDefaults struct {
|
||||
required *bool
|
||||
follow *bool
|
||||
hidden *bool
|
||||
recursive *bool
|
||||
pattern []string
|
||||
}
|
||||
|
||||
// ImportDefaults transports root-owned load defaults across the facade
|
||||
// boundary. Load options snapshot this value before source acquisition.
|
||||
type ImportDefaults struct {
|
||||
// Required controls absence handling when non-nil.
|
||||
Required *bool
|
||||
// Follow controls symlink following when non-nil.
|
||||
Follow *bool
|
||||
// Hidden controls hidden-entry discovery when non-nil.
|
||||
Hidden *bool
|
||||
// Recursive controls descendant directory traversal when non-nil.
|
||||
Recursive *bool
|
||||
// Pattern filters directory-search filenames; nil and empty differ.
|
||||
Pattern []string
|
||||
}
|
||||
|
||||
// OptionsWithImportDefaults copies root-owned defaults into ordinary options.
|
||||
func OptionsWithImportDefaults[T any](options Options[T], value ImportDefaults) Options[T] {
|
||||
options.importDefaults = privateImportDefaults(value)
|
||||
return options
|
||||
}
|
||||
|
||||
// LayeredOptionsWithImportDefaults copies root-owned defaults into grouped options.
|
||||
func LayeredOptionsWithImportDefaults[T any](options LayeredOptions[T], value ImportDefaults) LayeredOptions[T] {
|
||||
options.importDefaults = privateImportDefaults(value)
|
||||
return options
|
||||
}
|
||||
|
||||
func privateImportDefaults(value ImportDefaults) importDefaults {
|
||||
return snapshotImportDefaults(importDefaults{
|
||||
required: value.Required, follow: value.Follow, hidden: value.Hidden,
|
||||
recursive: value.Recursive, pattern: value.Pattern,
|
||||
})
|
||||
}
|
||||
|
||||
type resolvedImportPolicy struct {
|
||||
required bool
|
||||
follow bool
|
||||
hidden bool
|
||||
recursive bool
|
||||
pattern []string
|
||||
depth int
|
||||
depthSet bool
|
||||
exclude []string
|
||||
destination Path
|
||||
placement placementMode
|
||||
}
|
||||
|
||||
func snapshotImportDefaults(value importDefaults) importDefaults {
|
||||
return importDefaults{
|
||||
required: cloneBool(value.required),
|
||||
follow: cloneBool(value.follow),
|
||||
hidden: cloneBool(value.hidden),
|
||||
recursive: cloneBool(value.recursive),
|
||||
pattern: cloneStrings(value.pattern),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
return append(make([]string, 0, len(values)), values...)
|
||||
}
|
||||
|
||||
func validateImportDefaults(value importDefaults) []Diagnostic {
|
||||
for _, pattern := range value.pattern {
|
||||
if _, err := path.Match(pattern, ""); err != nil {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (resolvedImportPolicy, []Diagnostic) {
|
||||
defaults = snapshotImportDefaults(defaults)
|
||||
policy := resolvedImportPolicy{
|
||||
required: boolOr(spec.required, defaults.required, false),
|
||||
follow: true,
|
||||
hidden: true,
|
||||
recursive: false,
|
||||
destination: spec.destination.clone(),
|
||||
placement: spec.placement,
|
||||
depth: spec.depth,
|
||||
depthSet: spec.depthSet,
|
||||
exclude: cloneStrings(spec.exclude),
|
||||
}
|
||||
if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob {
|
||||
policy.follow = boolOr(spec.follow, defaults.follow, true)
|
||||
policy.hidden = boolOr(spec.hidden, defaults.hidden, true)
|
||||
}
|
||||
if spec.selector == importSelectorDirectory {
|
||||
policy.recursive = boolOr(spec.recursive, defaults.recursive, false)
|
||||
if spec.patternSet {
|
||||
policy.pattern = cloneStrings(spec.pattern)
|
||||
} else {
|
||||
policy.pattern = cloneStrings(defaults.pattern)
|
||||
}
|
||||
}
|
||||
if spec.depthSet && !policy.recursive {
|
||||
site := spec.fieldSites["depth"]
|
||||
location := site.location
|
||||
return resolvedImportPolicy{}, []Diagnostic{{
|
||||
Code: CodeImportInvalid, Severity: SeverityError,
|
||||
Path: site.path.clone(), Message: "depth requires effective recursive discovery",
|
||||
Primary: &location,
|
||||
}}
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func boolOr(explicit, application *bool, fallback bool) bool {
|
||||
if explicit != nil {
|
||||
return *explicit
|
||||
}
|
||||
if application != nil {
|
||||
return *application
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// parseImportSpecificationDirective validates every declaration before callers select candidates.
|
||||
func parseImportSpecificationDirective(node *document.Node, directivePath Path) ([]importDeclarationSpec, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||
}
|
||||
var specs []importDeclarationSpec
|
||||
var diagnostics []Diagnostic
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := directivePath.Key(entry.Key)
|
||||
if entry.Key != "imports" {
|
||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
|
||||
continue
|
||||
}
|
||||
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
|
||||
specs = append(specs, parsed...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
}
|
||||
return specs, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) {
|
||||
if node == nil || node.Kind != document.Sequence {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)}
|
||||
}
|
||||
var specs []importDeclarationSpec
|
||||
var diagnostics []Diagnostic
|
||||
for index, declarationNode := range node.Sequence {
|
||||
spec, issues := parseImportDeclarationSpec(declarationNode, declarationPath.Index(index))
|
||||
if len(issues) != 0 {
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
continue
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (importDeclarationSpec, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
||||
}
|
||||
spec := importDeclarationSpec{
|
||||
path: declarationPath.clone(),
|
||||
location: publicLocation(node.Location),
|
||||
placement: placementModeMapping,
|
||||
fieldSites: make(map[string]importFieldSite, len(node.Mapping)),
|
||||
}
|
||||
selectors := 0
|
||||
selectorIndex := -1
|
||||
for index, entry := range node.Mapping {
|
||||
if selectorForField(entry.Key) != 0 {
|
||||
selectors++
|
||||
selectorIndex = index
|
||||
}
|
||||
}
|
||||
if selectors != 1 {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path, dir, or glob selector", node)}
|
||||
}
|
||||
selectorEntry := node.Mapping[selectorIndex]
|
||||
spec.selector = selectorForField(selectorEntry.Key)
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := declarationPath.Key(entry.Key)
|
||||
spec.fieldSites[entry.Key] = importFieldSite{path: fieldPath.clone(), location: publicLocation(entry.Value.Location)}
|
||||
if selectorForField(entry.Key) != 0 {
|
||||
continue
|
||||
}
|
||||
if !importFieldApplies(entry.Key, spec.selector) {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not supported by this selector", entry.Value)}
|
||||
}
|
||||
switch entry.Key {
|
||||
case "required", "recursive", "follow", "hidden":
|
||||
if entry.Value.Kind != document.Boolean {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, entry.Key+" must be a boolean", entry.Value)}
|
||||
}
|
||||
value := entry.Value.Text == "true"
|
||||
switch entry.Key {
|
||||
case "required":
|
||||
spec.required = &value
|
||||
case "recursive":
|
||||
spec.recursive = &value
|
||||
case "follow":
|
||||
spec.follow = &value
|
||||
case "hidden":
|
||||
spec.hidden = &value
|
||||
}
|
||||
case "pattern":
|
||||
values, origins, ok, badIndex := parseStringSet(entry.Value)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "pattern", badIndex)}
|
||||
}
|
||||
if badIndex, ok := invalidWildcard(values); ok {
|
||||
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "pattern contains an invalid wildcard expression", badNode)}
|
||||
}
|
||||
spec.pattern, spec.patternSet = values, true
|
||||
case "exclude":
|
||||
values, origins, ok, badIndex := parseStringSet(entry.Value)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "exclude", badIndex)}
|
||||
}
|
||||
if badIndex, ok := invalidWildcard(values); ok {
|
||||
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "exclude contains an invalid wildcard expression", badNode)}
|
||||
}
|
||||
spec.exclude, spec.excludeSet = values, true
|
||||
case "depth":
|
||||
depth, ok := parseImportDepth(entry.Value)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "depth must be a nonnegative integer", entry.Value)}
|
||||
}
|
||||
spec.depth, spec.depthSet = depth, true
|
||||
case "destination":
|
||||
destination, diagnostic := parseImportDestination(entry.Value, fieldPath)
|
||||
if diagnostic != nil {
|
||||
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
spec.destination = destination
|
||||
case "placement":
|
||||
if entry.Value.Kind != document.String {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
||||
}
|
||||
switch entry.Value.Text {
|
||||
case "mapping":
|
||||
spec.placement = placementModeMapping
|
||||
case "sequence-item":
|
||||
spec.placement = placementModeSequenceItem
|
||||
default:
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
||||
}
|
||||
default:
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||
}
|
||||
}
|
||||
|
||||
selectorPath := declarationPath.Key(selectorEntry.Key)
|
||||
spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)}
|
||||
switch spec.selector {
|
||||
case importSelectorPath, importSelectorDirectory:
|
||||
values, origins, ok, badIndex := parseStringSet(selectorEntry.Value)
|
||||
if !ok {
|
||||
name := selectorEntry.Key
|
||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(selectorEntry.Value, selectorPath, name, badIndex)}
|
||||
}
|
||||
for index, value := range values {
|
||||
if !validImportPath(value) {
|
||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)}
|
||||
}
|
||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
||||
spec.selectorSites = append(spec.selectorSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)})
|
||||
}
|
||||
spec.paths = values
|
||||
case importSelectorGlob:
|
||||
if selectorEntry.Value.Kind != document.String || !validImportPath(selectorEntry.Value.Text) {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", selectorEntry.Value)}
|
||||
}
|
||||
segments, ok := validGlobPattern(selectorEntry.Value.Text)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", selectorEntry.Value)}
|
||||
}
|
||||
spec.glob, spec.globSegments = selectorEntry.Value.Text, segments
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func selectorForField(field string) importSelector {
|
||||
switch field {
|
||||
case "path":
|
||||
return importSelectorPath
|
||||
case "dir":
|
||||
return importSelectorDirectory
|
||||
case "glob":
|
||||
return importSelectorGlob
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func importFieldApplies(field string, selector importSelector) bool {
|
||||
switch field {
|
||||
case "required", "exclude", "destination", "placement":
|
||||
return true
|
||||
case "pattern", "recursive", "depth":
|
||||
return selector == importSelectorDirectory
|
||||
case "follow", "hidden":
|
||||
return selector == importSelectorDirectory || selector == importSelectorGlob
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseStringSet(node *document.Node) ([]string, []int, bool, int) {
|
||||
if node.Kind == document.String {
|
||||
return []string{node.Text}, []int{-1}, true, -1
|
||||
}
|
||||
if node.Kind != document.Sequence {
|
||||
return nil, nil, false, -1
|
||||
}
|
||||
values := make([]string, 0, len(node.Sequence))
|
||||
origins := make([]int, 0, len(node.Sequence))
|
||||
seen := make(map[string]struct{}, len(node.Sequence))
|
||||
for index, item := range node.Sequence {
|
||||
if item.Kind != document.String {
|
||||
return nil, nil, false, index
|
||||
}
|
||||
if _, exists := seen[item.Text]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.Text] = struct{}{}
|
||||
values = append(values, item.Text)
|
||||
origins = append(origins, index)
|
||||
}
|
||||
return values, origins, true, -1
|
||||
}
|
||||
|
||||
func stringSetDiagnostic(node *document.Node, fieldPath Path, field string, badIndex int) Diagnostic {
|
||||
if badIndex >= 0 {
|
||||
badPath, item := listMemberSite(node, fieldPath, badIndex)
|
||||
return nodeImportDiagnostic(CodeImportInvalid, badPath, field+" members must be strings", item)
|
||||
}
|
||||
return nodeImportDiagnostic(CodeImportInvalid, fieldPath, field+" must be a string or a flat list of strings", node)
|
||||
}
|
||||
|
||||
func invalidWildcard(values []string) (int, bool) {
|
||||
for index, value := range values {
|
||||
if _, err := path.Match(value, ""); err != nil {
|
||||
return index, true
|
||||
}
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func listMemberSite(node *document.Node, fieldPath Path, index int) (Path, *document.Node) {
|
||||
if node.Kind != document.Sequence || index < 0 {
|
||||
return fieldPath, node
|
||||
}
|
||||
return fieldPath.Index(index), node.Sequence[index]
|
||||
}
|
||||
|
||||
func parseImportDepth(node *document.Node) (int, bool) {
|
||||
if node.Kind != document.Number || node.Text == "" {
|
||||
return 0, false
|
||||
}
|
||||
if node.Text == "-0" {
|
||||
return 0, true
|
||||
}
|
||||
var value uint64
|
||||
maxInt := uint64(^uint(0) >> 1)
|
||||
for index := 0; index < len(node.Text); index++ {
|
||||
character := node.Text[index]
|
||||
if character < '0' || character > '9' {
|
||||
return 0, false
|
||||
}
|
||||
digit := uint64(character - '0')
|
||||
if value > (maxInt-digit)/10 {
|
||||
value = maxInt
|
||||
continue // Saturate the bound, but validate every remaining character.
|
||||
}
|
||||
value = value*10 + digit
|
||||
}
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
func parseImportDestination(node *document.Node, fieldPath Path) (Path, *Diagnostic) {
|
||||
if node.Kind != document.Sequence {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", node)
|
||||
return Path{}, &diagnostic
|
||||
}
|
||||
relative := Path{segments: make([]pathSegment, 0, len(node.Sequence))}
|
||||
for index, item := range node.Sequence {
|
||||
memberPath := fieldPath.Index(index)
|
||||
if item.Kind != document.String {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)
|
||||
return Path{}, &diagnostic
|
||||
}
|
||||
member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}}
|
||||
if _, err := normalizeDestination(member); err != nil {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)
|
||||
return Path{}, &diagnostic
|
||||
}
|
||||
relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text})
|
||||
}
|
||||
normalized, err := normalizeDestination(relative)
|
||||
if err != nil {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", node)
|
||||
return Path{}, &diagnostic
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
243
internal/engine/import_spec_test.go
Normal file
243
internal/engine/import_spec_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseImportDeclarationSpecNormalizesPresenceAndPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, `
|
||||
- path: [one.yaml, one.yaml, two.yaml]
|
||||
required: false
|
||||
destination: [services]
|
||||
placement: sequence-item
|
||||
- dir: []
|
||||
recursive: true
|
||||
pattern: []
|
||||
exclude: []
|
||||
- glob: config/*.yaml
|
||||
`)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(specs) != 3 {
|
||||
t.Fatalf("spec count = %d, want 3", len(specs))
|
||||
}
|
||||
first := specs[0]
|
||||
if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" {
|
||||
t.Fatalf("path selector = %#v", first)
|
||||
}
|
||||
if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != placementModeSequenceItem {
|
||||
t.Fatalf("explicit declaration values = %#v", first)
|
||||
}
|
||||
if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" {
|
||||
t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String())
|
||||
}
|
||||
second := specs[1]
|
||||
if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || !second.excludeSet || second.exclude == nil {
|
||||
t.Fatalf("empty list presence was lost: %#v", second)
|
||||
}
|
||||
third := specs[2]
|
||||
if third.selector != importSelectorGlob || third.glob != "config/*.yaml" || len(third.globSegments) != 2 {
|
||||
t.Fatalf("glob selector = %#v", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportDeclarationSpecValidatesSelectorMatrixAndTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name, yaml, path string
|
||||
}{
|
||||
{name: "missing selector", yaml: " - required: true\n", path: "/configkit/imports/0"},
|
||||
{name: "multiple selectors", yaml: " - path: one.yaml\n dir: fragments\n", path: "/configkit/imports/0"},
|
||||
{name: "removed optional", yaml: " - path: one.yaml\n optional: true\n", path: "/configkit/imports/0/optional"},
|
||||
{name: "inapplicable field", yaml: " - path: one.yaml\n pattern: '*.yaml'\n", path: "/configkit/imports/0/pattern"},
|
||||
{name: "selector null", yaml: " - path: null\n", path: "/configkit/imports/0/path"},
|
||||
{name: "empty path", yaml: " - path: ''\n", path: "/configkit/imports/0/path"},
|
||||
{name: "nested selector list", yaml: " - path: [[one.yaml]]\n", path: "/configkit/imports/0/path/0"},
|
||||
{name: "null selector member", yaml: " - dir: [fragments, null]\n", path: "/configkit/imports/0/dir/1"},
|
||||
{name: "empty glob", yaml: " - glob: ''\n", path: "/configkit/imports/0/glob"},
|
||||
{name: "recursive glob wildcard", yaml: " - glob: '**/*.yaml'\n", path: "/configkit/imports/0/glob"},
|
||||
{name: "null boolean", yaml: " - path: one.yaml\n required: null\n", path: "/configkit/imports/0/required"},
|
||||
{name: "bad pattern member with empty roots", yaml: " - dir: []\n pattern: ['*', '[']\n", path: "/configkit/imports/0/pattern/1"},
|
||||
{name: "bad exclusion member", yaml: " - glob: '*.yaml'\n exclude: ['[']\n", path: "/configkit/imports/0/exclude/0"},
|
||||
{name: "invalid destination", yaml: " - path: one.yaml\n destination: [configkit]\n", path: "/configkit/imports/0/destination/0"},
|
||||
{name: "invalid placement", yaml: " - path: one.yaml\n placement: sequence\n", path: "/configkit/imports/0/placement"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, diagnostics := parseImportSpecsYAML(t, test.yaml)
|
||||
if len(diagnostics) == 0 {
|
||||
t.Fatal("expected a declaration diagnostic")
|
||||
}
|
||||
if diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.path {
|
||||
t.Fatalf("diagnostic = %#v, want %s at %s", diagnostics[0], CodeImportInvalid, test.path)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.Line == 0 {
|
||||
t.Fatalf("diagnostic has no source location: %#v", diagnostics[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportDeclarationSpecValidatesDepthSyntaxAndPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
valid bool
|
||||
want int
|
||||
}{
|
||||
{value: "0", valid: true, want: 0},
|
||||
{value: "-0", valid: true, want: 0},
|
||||
{value: "999999999999999999999999999999999999", valid: true, want: int(^uint(0) >> 1)},
|
||||
{value: "-1"},
|
||||
{value: "1.5"},
|
||||
{value: "1e3"},
|
||||
{value: "'2'"},
|
||||
{value: "true"},
|
||||
{value: "null"},
|
||||
} {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - dir: []\n recursive: true\n depth: "+test.value+"\n")
|
||||
if !test.valid {
|
||||
if len(diagnostics) == 0 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" {
|
||||
t.Fatalf("diagnostics = %#v, want invalid depth", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || len(specs) != 1 || specs[0].depth != test.want {
|
||||
t.Fatalf("specs, diagnostics = %#v, %#v; want depth %d", specs, diagnostics, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, " - dir: []\n depth: 1\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
_, diagnostics = resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" {
|
||||
t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics)
|
||||
}
|
||||
|
||||
recursive := true
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{recursive: &recursive})
|
||||
if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet {
|
||||
t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
omittedSpecs, diagnostics := parseImportSpecsYAML(t, " - dir: fragments\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil {
|
||||
t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics)
|
||||
}
|
||||
|
||||
specs, diagnostics := parseImportSpecsYAML(t, `
|
||||
- dir: fragments
|
||||
required: false
|
||||
recursive: false
|
||||
follow: false
|
||||
hidden: false
|
||||
pattern: []
|
||||
`)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
appRequired, appRecursive, appFollow, appHidden := true, true, true, true
|
||||
defaults := importDefaults{
|
||||
required: &appRequired,
|
||||
recursive: &appRecursive,
|
||||
follow: &appFollow,
|
||||
hidden: &appHidden,
|
||||
pattern: []string{"*.yaml"},
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], defaults)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if policy.required || policy.recursive || policy.follow || policy.hidden || policy.pattern == nil || len(policy.pattern) != 0 {
|
||||
t.Fatalf("explicit values did not override application defaults: %#v", policy)
|
||||
}
|
||||
|
||||
pathSpecs, diagnostics := parseImportSpecsYAML(t, " - path: file.yaml\n")
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
pathPolicy, diagnostics := resolveImportPolicy(pathSpecs[0], defaults)
|
||||
if len(diagnostics) != 0 || pathPolicy.required != true || pathPolicy.recursive || pathPolicy.pattern != nil {
|
||||
t.Fatalf("path policy applied unsupported defaults: %#v, %#v", pathPolicy, diagnostics)
|
||||
}
|
||||
defaulted, diagnostics := resolveImportPolicy(omittedSpecs[0], defaults)
|
||||
if len(diagnostics) != 0 || len(defaulted.pattern) != 1 || defaulted.pattern[0] != "*.yaml" {
|
||||
t.Fatalf("directory pattern default = %#v, diagnostics = %#v", defaulted.pattern, diagnostics)
|
||||
}
|
||||
|
||||
snapshot := snapshotImportDefaults(defaults)
|
||||
*defaults.required = false
|
||||
defaults.pattern[0] = "*.toml"
|
||||
if snapshot.required == nil || !*snapshot.required || snapshot.pattern[0] != "*.yaml" {
|
||||
t.Fatalf("snapshot shares mutable inputs: %#v", snapshot)
|
||||
}
|
||||
empty := snapshotImportDefaults(importDefaults{pattern: []string{}})
|
||||
if empty.pattern == nil || len(empty.pattern) != 0 {
|
||||
t.Fatalf("snapshot lost explicit empty pattern: %#v", empty)
|
||||
}
|
||||
if snapshotImportDefaults(importDefaults{}).pattern != nil {
|
||||
t.Fatal("snapshot changed omitted pattern into explicit empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidImportDefaultPatternsFailBeforeReadingSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")}
|
||||
_, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{
|
||||
importDefaults: importDefaults{pattern: []string{"["}},
|
||||
})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("load error = %T %v, want invalid-options LoadError", err, err)
|
||||
}
|
||||
if reader.reads != 0 {
|
||||
t.Fatalf("reader consumed %d bytes before invalid defaults failed", reader.reads)
|
||||
}
|
||||
}
|
||||
|
||||
func parseImportSpecsYAML(t *testing.T, declarations string) ([]importDeclarationSpec, []Diagnostic) {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes("import specification fixture", []byte("configkit:\n imports:\n"+declarations)), "source-1", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
index := documentEntryIndex(parsed.document.Mapping, directiveKey)
|
||||
if index < 0 {
|
||||
t.Fatal("missing root directive in test fixture")
|
||||
}
|
||||
return parseImportSpecificationDirective(parsed.document.Mapping[index].Value, Keys(directiveKey))
|
||||
}
|
||||
|
||||
type countingImportSpecReader struct {
|
||||
io.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func (reader *countingImportSpecReader) Read(buffer []byte) (int, error) {
|
||||
read, err := reader.Reader.Read(buffer)
|
||||
reader.reads += read
|
||||
return read, err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user