Compare commits
90 Commits
v0.8.0
...
bc6b977585
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
11
README.md
11
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
|
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
||||||
source groups with an independently selected main input. Sources can be host
|
source groups with an independently selected main input. Sources can be host
|
||||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
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,
|
between groups combines mappings recursively. Both entry points return a complete,
|
||||||
validated configuration with detached metadata and safe diagnostics.
|
validated configuration with detached metadata and safe diagnostics.
|
||||||
Applications may perform audited defaulting and normalization through a resolver,
|
Applications may perform audited defaulting and normalization through a resolver,
|
||||||
@@ -33,6 +33,15 @@ sequences as whole values. See the [sequence contract](docs/config.md#strict-doc
|
|||||||
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
|
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
|
||||||
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
|
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.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
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
|
|||||||
@@ -43,12 +43,80 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), 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)}
|
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 {
|
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||||
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.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)
|
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 {
|
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
# 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
|
Date: 2026-09-06
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Status
|
## 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
|
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||||
owns current behavior. The completed implementation plan is preserved in Git
|
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`.
|
history at `83c59ea`.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted and implemented. The [configuration contract](../config.md#strict-document-composition)
|
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||||
defines current behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
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
|
shows the maintained example. The completed roadmap, implementation plan, and
|
||||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||||
|
|||||||
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 will define declaration syntax, identity comparison,
|
||||||
|
omission and empty-value behavior, and diagnostics when the feature is
|
||||||
|
implemented.
|
||||||
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 and consumer guidance
|
||||||
|
must explain these behavior changes when they are implemented.
|
||||||
167
docs/config.md
167
docs/config.md
@@ -68,7 +68,7 @@ contributes no source, and continues through defaults, decode, and projection. A
|
|||||||
missing file remains an error; it is never interpreted as no input.
|
missing file remains an error; it is never interpreted as no input.
|
||||||
|
|
||||||
`Options` accepts the application definition, ordinary-source `Destination` and
|
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||||
`PlacementMode`, explicit path and glob imports, one selected overlay, filesystem
|
`PlacementMode`, explicit imports, one selected overlay, filesystem
|
||||||
context, explicit CLI overrides, and shared source-version controls. The definition
|
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||||
may register strict sequence composition paths through
|
may register strict sequence composition paths through
|
||||||
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
|
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
|
||||||
@@ -220,6 +220,13 @@ 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
|
types, missing or non-mapping parents, and cycles fail the load with value-free
|
||||||
diagnostics.
|
diagnostics.
|
||||||
|
|
||||||
|
Within each sibling scope, direct mapping 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 the mappings in the resulting configuration.
|
||||||
|
|
||||||
`extends` is reserved structural input across ordinary and layered loads, every
|
`extends` is reserved structural input across ordinary and layered loads, every
|
||||||
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||||
consumed before typed decoding and need not appear in the application's Go
|
consumed before typed decoding and need not appear in the application's Go
|
||||||
@@ -422,14 +429,15 @@ sequence records replacement at the sequence path; history does not imply
|
|||||||
identity-based merging across tiers.
|
identity-based merging across tiers.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||||
has an overlay-layer composite origin with the lower contributing locations followed
|
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||||
by the overlay location; an empty mapping is still an explicit container even though
|
composite origin with the lower contributing locations followed by the overlay
|
||||||
it contributes no children. Resolution origins are synthetic: they have layer
|
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`
|
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
removed. Source records and IDs retain [acquisition order](#explicit-imports),
|
||||||
selected overlay, then synthetic CLI records in supplied order.
|
including the underlay ordering described in [layered loading](#layered-loading).
|
||||||
|
|
||||||
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||||
lists concrete transfers from contributor paths through destinations, with
|
lists concrete transfers from contributor paths through destinations, with
|
||||||
@@ -492,9 +500,11 @@ historical path. Its effective value and each chronological before/after value a
|
|||||||
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
||||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
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
|
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
|
application-default or Go-zero baseline, followed by relevant underlay and main-tier
|
||||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
contributions in precedence order, the selected overlay, supplied CLI records,
|
||||||
resolution operations. Structural inheritance events appear after source replay
|
and audited net resolution operations. Within each strict tier, replay follows
|
||||||
|
[scoped assembly order](#explicit-imports), not source-record acquisition order.
|
||||||
|
Structural inheritance events appear after source replay
|
||||||
and before typed reconciliation, including consumed or canceled `extends` input
|
and before typed reconciliation, including consumed or canceled `extends` input
|
||||||
and equal-valued child replacements. Overlapping parent/child resolution records
|
and equal-valued child replacements. Overlapping parent/child resolution records
|
||||||
are rendered
|
are rendered
|
||||||
@@ -812,11 +822,11 @@ completed configuration.
|
|||||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||||
Shared mapping containers retain all contributing value locations as their composite
|
Shared mapping containers retain all contributing value locations as their composite
|
||||||
base origin. A conflict reports the first claim as its primary location and every
|
base origin. A conflict reports the first claim in
|
||||||
remaining claim as related locations, in deterministic source and declaration
|
[scoped assembly order](#explicit-imports) as its primary location and every
|
||||||
traversal order. Traversal order never authorizes replacement. Composition clones
|
remaining claim as related locations in the same order. Assembly order never
|
||||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
authorizes replacement. Composition clones the resulting tree and its ordered
|
||||||
input document.
|
claim records rather than mutating or aliasing an input document.
|
||||||
|
|
||||||
## Selected overlays
|
## Selected overlays
|
||||||
|
|
||||||
@@ -907,14 +917,18 @@ zero.
|
|||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
application decoding. Each sequence item is a mapping with exactly one `path`,
|
||||||
string `path` or `glob`, an optional boolean `optional`, and an optional
|
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
|
||||||
`destination` sequence of strings. Every destination string is one literal
|
`glob` accepts one path pattern string. The optional `required` boolean controls
|
||||||
mapping key and follows the input-destination validation rules above; quote
|
whether an empty selection or a missing named path is an error. Its default is
|
||||||
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
|
false, so missing imports are absent unless `required: true` is specified. The
|
||||||
and non-string members are `import.invalid`, as are all other unsupported shapes
|
removed `optional` field is invalid. `destination` is an optional sequence of
|
||||||
and fields. A `configkit` key below the root is invalid. With imports disabled,
|
strings. Every destination string is one literal mapping key and follows the
|
||||||
any occurrence remains `import.disabled`.
|
input-destination validation rules above; quote numeric-looking keys so YAML
|
||||||
|
reads them as strings. Null, scalar destinations, and non-string members are
|
||||||
|
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
|
||||||
|
key below the root is invalid. With imports disabled, any occurrence remains
|
||||||
|
`import.disabled`.
|
||||||
|
|
||||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||||
@@ -923,27 +937,85 @@ directive keys in root files and imported fragments, and rename any application
|
|||||||
fields previously named `configkit` to avoid the newly reserved key.
|
fields previously named `configkit` to avoid the newly reserved key.
|
||||||
|
|
||||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
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
|
An empty `path: []` selects nothing; it succeeds even when `required: true` because
|
||||||
body before its imports. Order stabilizes source records and diagnostics but never
|
there are no named targets to be missing. A nonempty required path list verifies
|
||||||
permits replacement. Required missing paths fail; `optional: true` suppresses only
|
every named target, including targets later excluded from selection.
|
||||||
target nonexistence, not invalid paths, containment failures, directories,
|
|
||||||
permissions, parse errors, or composition errors.
|
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
|
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
||||||
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
|
classes, and backslash escapes are supported, while recursive `**` syntax is
|
||||||
Backslashes escape pattern characters rather than acting as directory separators.
|
invalid. Backslashes escape pattern characters rather than acting as directory
|
||||||
ConfigKit validates the complete pattern before filesystem traversal, walks only
|
separators. ConfigKit validates the complete pattern before filesystem traversal,
|
||||||
directories selected by preceding segments, and reads wildcard directories in
|
walks only directories selected by preceding segments, and reads wildcard
|
||||||
bounded batches. Every examined entry consumes the separate visit budget, including
|
directories in bounded batches. Every examined entry consumes the separate visit
|
||||||
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
|
budget, including nonmatches. Literal components are explicit path components;
|
||||||
each declaration before depth-first source traversal. A required zero-match glob is
|
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
|
||||||
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
|
final candidate basenames.
|
||||||
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.
|
|
||||||
|
|
||||||
Each path or glob declaration may include `placement: mapping` or
|
`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.
|
||||||
|
|
||||||
|
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; a higher
|
||||||
|
tier still replaces a lower tier's sequence values wholesale. 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,
|
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
||||||
including every match of a glob; placement is not inherited. Sequence-item
|
including every match of a glob; placement is not inherited. Sequence-item
|
||||||
placement requires a nonempty effective destination and wraps the imported
|
placement requires a nonempty effective destination and wraps the imported
|
||||||
@@ -964,8 +1036,8 @@ 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.
|
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.
|
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.
|
Physical paths continue to resolve from the file that declares each import.
|
||||||
An actual empty mapping contributes an empty item, while an optional unmatched
|
An actual empty mapping contributes an empty item, while an unmatched declaration
|
||||||
path or glob contributes nothing.
|
with default or explicit `required: false` contributes nothing.
|
||||||
|
|
||||||
For host files, the configuration root defaults to the selected file's canonical
|
For host files, the configuration root defaults to the selected file's canonical
|
||||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||||
@@ -984,8 +1056,13 @@ ConfigKit canonicalizes symlinks,
|
|||||||
checks containment with relative paths, and opens the canonical root-relative target
|
checks containment with relative paths, and opens the canonical root-relative target
|
||||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
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
|
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||||
distinct paths remain distinct sources. Native filesystems use slash-path operations
|
distinct paths remain distinct sources. Native filesystems use slash-path
|
||||||
and their own symlink behavior; imports stay in their selected namespace and root.
|
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
|
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
|
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||||
|
|||||||
@@ -55,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
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
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
|
## Shared source-version checks
|
||||||
|
|
||||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||||
@@ -167,11 +185,26 @@ import, source-location, and ownership behavior.
|
|||||||
## Identified prompt sequences
|
## Identified prompt sequences
|
||||||
|
|
||||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
||||||
definition registration for the literal `prompts` path and `id` field. Its main
|
definition registration for the literal `prompts` path and `id` field. Its
|
||||||
file imports one prompt by path and others by glob, placing each source as an
|
load-wide filename defaults accept two patterns, an explicit directory
|
||||||
item. A nested ordinary helper import supplies an item's ID. The main tier
|
declaration overrides those patterns, and an exclusion omits one matching
|
||||||
strictly appends the distinct records in contribution order; its completed
|
candidate. A nested ordinary helper import supplies an item's ID. A separate
|
||||||
sequence replaces the lower-priority default collection as one value.
|
empty optional directory search is read from `SourceInfo.ImportSelections`.
|
||||||
|
The executable example prints both the scheduled prompt order and the single
|
||||||
|
empty-search outcome; the behavior test also follows a mounted resource through
|
||||||
|
its effective metadata origin to the declaring source context. The completed
|
||||||
|
main sequence replaces the lower-priority default collection as one value.
|
||||||
|
The maintained [executable example and behavior tests](../../examples/consumers/prompt_catalog_test.go)
|
||||||
|
show the scheduled result, empty-search inspection, excluded candidate, and
|
||||||
|
resource lookup without a consumer-side discovery pass. The corresponding
|
||||||
|
[complex fixture](../../examples/consumers/testdata/prompt-catalog/config.yml)
|
||||||
|
and [simple required-file fixture](../../examples/consumers/testdata/prompt-catalog/simple.yml)
|
||||||
|
are complete local examples.
|
||||||
|
|
||||||
|
[`LoadRequiredPromptCatalog`](../../examples/consumers/prompt_catalog.go) and
|
||||||
|
[`Example_requiredPromptCatalog`](../../examples/consumers/prompt_catalog_test.go)
|
||||||
|
keep the smaller adoption path visible: one explicitly required file import,
|
||||||
|
the same identity registration, and the same application validation.
|
||||||
|
|
||||||
The example's `validatePromptCatalog` remains application-owned. It checks that
|
The example's `validatePromptCatalog` remains application-owned. It checks that
|
||||||
prompt names and message lists satisfy this consumer's requirements after
|
prompt names and message lists satisfy this consumer's requirements after
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ Accepted decisions:
|
|||||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
- [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
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -148,6 +152,20 @@ git show 008d2ba:docs/roadmap/implementation.md
|
|||||||
git show 008d2ba:docs/roadmap/remediation.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`.
|
||||||
|
|
||||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||||
|
|
||||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
@@ -197,6 +215,7 @@ 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'` |
|
| 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'` |
|
| 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'` |
|
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||||
|
|||||||
80
docs/internal/imports.md
Normal file
80
docs/internal/imports.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 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. 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`.
|
||||||
@@ -12,9 +12,13 @@ Implemented component owners:
|
|||||||
and sequence-item placement, definition-registered distinct-ID sequence
|
and sequence-item placement, definition-registered distinct-ID sequence
|
||||||
composition within strict tiers, and immediate-parent-relative import
|
composition within strict tiers, and immediate-parent-relative import
|
||||||
placement within that assembly. Higher-priority sequences replace lower
|
placement within that assembly. Higher-priority sequences replace lower
|
||||||
sequences as whole values. The engine also owns destination-local sibling
|
sequences as whole values. The engine also owns dependency-ready,
|
||||||
inheritance before typed decoding, plus shared source-version checking with
|
destination-local sibling inheritance before typed decoding, plus shared
|
||||||
sanitized declaration evidence.
|
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
|
It retains each source's effective placement alongside physical source
|
||||||
identity and owns schema
|
identity and owns schema
|
||||||
inspection, lifecycle coordination, callback auditing,
|
inspection, lifecycle coordination, callback auditing,
|
||||||
@@ -67,4 +71,8 @@ post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-desti
|
|||||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||||
records the source-version decision.
|
records the source-version decision.
|
||||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||||
sequence placement and composition.
|
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. 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
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-10-16
|
**Last Updated**: 2026-10-22
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
|
|
||||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
|||||||
Constraints must reflect deliberately selected requirements and decisions.
|
Constraints must reflect deliberately selected requirements and decisions.
|
||||||
Do not promote incidental implementation choices into architectural invariants.
|
Do not promote incidental implementation choices into architectural invariants.
|
||||||
|
|
||||||
|
The collection-policy and CLI-ordering requirements below reflect accepted
|
||||||
|
[ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The
|
||||||
|
[sequence roadmap](../roadmap/sequences.md) tracks the work to implement them;
|
||||||
|
the configuration contract continues to describe implemented behavior.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
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
|
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||||
implemented surface against representative simple and complex shapes.
|
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
|
## Responsibility Boundaries
|
||||||
|
|
||||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
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
|
Library code must not depend on consuming applications or initialize their
|
||||||
runtime resources. It must not perform implicit network access, discover
|
runtime resources. It must not perform implicit network access, discover
|
||||||
application-specific paths, or change process-global state during ordinary `Load`.
|
application-specific paths, or change process-global state during ordinary `Load`.
|
||||||
|
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
|
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
|
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||||
must never run implicitly as part of configuration loading.
|
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
|
the precise completion and ownership behavior. The proposed decision record is
|
||||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
[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
|
## Public API And Dependency Direction
|
||||||
|
|
||||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||||
@@ -161,41 +240,44 @@ framework on this library.
|
|||||||
|
|
||||||
## Configuration Integrity
|
## Configuration Integrity
|
||||||
|
|
||||||
Within a configuration tier, independent contributions use strict composition:
|
Within a configuration tier, independent contributions use strict composition.
|
||||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
Shared mappings combine recursively. Sequences with an applicable identity
|
||||||
are conflicts, including equal values, except that sequences at explicitly
|
combine distinct records and reject duplicate identities, including equal
|
||||||
registered paths append records with distinct present string identities. Between
|
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||||
precedence tiers, intentional overlay combines mappings recursively, retains
|
values. Conflicting identity or operation declarations among peers also fail.
|
||||||
omitted lower-priority children,
|
Traversal order establishes stable reporting and record order; it does not grant
|
||||||
and replaces other present values according to the existing overlay rules.
|
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
records the distinction between strict composition and intentional overrides.
|
||||||
reporting, not permission to override. The distinction and its rationale are
|
|
||||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
|
||||||
|
|
||||||
Sequence-item placement and sequence registration are explicit source and
|
|
||||||
definition controls; registration does not change precedence replacement. The
|
|
||||||
[configuration contract](../config.md#strict-document-composition) defines the
|
|
||||||
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
|
|
||||||
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
|
||||||
shows the implemented subset. This feature does not authorize arbitrary
|
|
||||||
configurable merge strategies.
|
|
||||||
|
|
||||||
The main document and its imports form a strictly composed tier. Explicit
|
The main document and its imports form a strictly composed tier. Explicit
|
||||||
underlay groups compose strictly within each group, then combine recursively
|
underlay groups compose strictly within each group, then combine from lowest to
|
||||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||||
determine its precedence role. Input-specific constraints, including CLI path
|
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
other present values retain the established replacement rules. Source acquisition
|
||||||
semantics do not require identical acquisition or lifetime behavior from every
|
kind does not determine precedence. Input-specific constraints, including version
|
||||||
source kind.
|
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
|
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||||
resolve structurally in the same parser-independent representation, using the
|
parser-independent document representation. Preserve existing mapping-sibling
|
||||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
scopes and allow an identified sequence to provide a sibling scope for its
|
||||||
consumer registration mechanism. Enclosing relationships precede nested
|
records. A record may inherit from one parent in that sequence, selected by
|
||||||
destination-local resolution; typed defaults, application resolution, and
|
complete identity. The child supplies and keeps its own identity while inheriting
|
||||||
secret lookup retain their existing lifecycle roles. The [configuration
|
the parent's other settings. Parents remain ordinary records subject to normal
|
||||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
validation.
|
||||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
|
||||||
|
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
|
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||||
@@ -204,13 +286,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
|||||||
|
|
||||||
User-controlled precedence is application defaults, ordered underlays, composed
|
User-controlled precedence is application defaults, ordered underlays, composed
|
||||||
main input, selected overlay, then explicitly supplied CLI overrides.
|
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||||
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
|
This priority order is distinct from processing order: typed defaults are applied
|
||||||
Domain resolution follows these
|
at decoding, and application resolution follows CLI input under the lifecycle
|
||||||
inputs and must remain recorded, not become a hidden override channel.
|
rules above.
|
||||||
|
|
||||||
Preserve node-based composition until the effective input is ready for typed
|
Preserve node-based composition through source assembly and inheritance until
|
||||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
typed decoding. Preserve presence and source information through later CLI and
|
||||||
values must not be used to infer whether input was absent. Final configuration,
|
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
|
metadata, and effective inspection must agree, including application resolution
|
||||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
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,
|
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||||
@@ -227,6 +310,72 @@ comparison and disclosure behavior, the [component overview](../internal/overvie
|
|||||||
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||||
records the accepted decision.
|
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
|
||||||
|
when implemented; the roadmap owns those details until then.
|
||||||
|
|
||||||
## Ownership And Confidentiality
|
## Ownership And Confidentiality
|
||||||
|
|
||||||
Returned configuration and metadata must not share mutable state across loads.
|
Returned configuration and metadata must not share mutable state across loads.
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ composition. Precedence still replaces sequences as whole values. This entry
|
|||||||
tracks broader identified-record behavior that remains deferred and is not part
|
tracks broader identified-record behavior that remains deferred and is not part
|
||||||
of that implemented contract.
|
of that implemented contract.
|
||||||
|
|
||||||
|
The active [sequence roadmap](sequences.md) now selects a narrower design for
|
||||||
|
collection policy, compound scalar identities, final application-registered
|
||||||
|
identity validation, and inheritance within identified sequences. It owns those
|
||||||
|
selected behaviors and their remaining decisions. The original broader proposal
|
||||||
|
below remains background for further consideration, particularly recursive
|
||||||
|
combination of matching record bodies; it does not override the active roadmap.
|
||||||
|
|
||||||
### Motivation and proposed behavior
|
### Motivation and proposed behavior
|
||||||
|
|
||||||
Allow applications to declare that selected sequences contain records identified
|
Allow applications to declare that selected sequences contain records identified
|
||||||
|
|||||||
894
docs/roadmap/implementation.md
Normal file
894
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
# Sequence Identity And Combination Implementation Plan
|
||||||
|
|
||||||
|
Status: ready for implementation; no stages completed.
|
||||||
|
|
||||||
|
Audience: a Luna coding agent implementing one numbered stage per prompt, in
|
||||||
|
order. This plan starts at commit
|
||||||
|
`4f941f8ba4c9a9ed0afa87b07fe6ba29f131d1c6`. It implements the settled
|
||||||
|
[sequence roadmap](sequences.md), [ADR 0017](../adr/0017-collection-sequence-policy.md),
|
||||||
|
and [ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
|
## Working Rules
|
||||||
|
|
||||||
|
- Read [development guidance](../development.md) and the applicable policies,
|
||||||
|
especially [agents](../policy/agents.md), [architecture](../policy/architecture.md),
|
||||||
|
[testing](../policy/testing.md), and [documentation](../policy/documentation.md).
|
||||||
|
Prefer the repository knowledge graph for code discovery.
|
||||||
|
- The roadmap owns the selected feature semantics. This plan supplies execution
|
||||||
|
order and implementation choices, not additional product requirements. Preserve
|
||||||
|
unchanged source, parser, version, schema, ownership, and inspection contracts.
|
||||||
|
- Complete each stage's implementation and behavioral tests together. Record its
|
||||||
|
completion and validation here, then commit the nonempty unit with a short,
|
||||||
|
plain-English message. Preserve unrelated changes. Do not publish intermediate
|
||||||
|
stages or a release, update consumers' dependencies, or wait for CI.
|
||||||
|
- Run focused tests while iterating and `./scripts/check.sh` before handing off
|
||||||
|
each code stage. Use the installed compatible Go toolchain with dependencies
|
||||||
|
already acquired, as the development guide describes. Add race coverage at the
|
||||||
|
ownership milestones below. These are implementation checks, not new release
|
||||||
|
gates. Reuse successful checks for unchanged content rather than repeating them
|
||||||
|
at a documentation-only milestone. Do not require human platform validation.
|
||||||
|
- Update affected declaration comments and canonical current-behavior prose when
|
||||||
|
behavior lands. Keep incomplete target behavior in this roadmap/plan until it
|
||||||
|
works. The final documentation stage reconciles the cross-feature narrative;
|
||||||
|
it is not permission to leave misleading current contracts in earlier stages.
|
||||||
|
- Transitional internal adapters are acceptable where specified. Remove them by
|
||||||
|
completion. Do not add public compatibility aliases, permanent feature flags,
|
||||||
|
alternate semantics, new dependencies, new resource caps, or an extension/plugin
|
||||||
|
framework for this work.
|
||||||
|
- Preparation stages keep existing public behavior working and test new machinery
|
||||||
|
privately. Activation stages integrate values, policy, metadata, and relevant
|
||||||
|
failure handling together; do not defer correct inspection to a later stage.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Public Registration Surface
|
||||||
|
|
||||||
|
Replace `SequenceComposition` and `Definition.SequenceCompositions` with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SequenceIdentity struct { /* private, root-owned registration state */ }
|
||||||
|
type SequenceSelector []SequenceSelectorSegment
|
||||||
|
type SequenceSelectorSegment struct { /* private literal-or-any-mapping-key state */ }
|
||||||
|
|
||||||
|
func SequenceLiteralKey(key string) SequenceSelectorSegment
|
||||||
|
func SequenceAnyMappingKey() SequenceSelectorSegment
|
||||||
|
func SequenceIdentityAt(path Path, fields ...string) SequenceIdentity
|
||||||
|
func SequenceIdentityMatching(selector SequenceSelector, fields ...string) SequenceIdentity
|
||||||
|
|
||||||
|
// Field on Definition[T]:
|
||||||
|
// SequenceIdentities []SequenceIdentity
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, the reusable definition can contain both registrations:
|
||||||
|
|
||||||
|
```go
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact constructor converts mapping-only `Path` segments into literal selector
|
||||||
|
segments. Preserve invalid index segments long enough to report an ordinary
|
||||||
|
definition error; do not panic, silently drop them, or change `Path` semantics.
|
||||||
|
The zero selector is the exact root selector, harmless under the existing
|
||||||
|
mapping-root schema. A zero selector segment denotes the literal empty key,
|
||||||
|
not a wildcard. Empty field names are valid literal field names; an empty field
|
||||||
|
set is invalid. Constructors snapshot slices; load-entry conversion also takes
|
||||||
|
an owned snapshot. Definition validation, not construction, reports errors.
|
||||||
|
Root types and functions must not expose internal types or bridge methods.
|
||||||
|
|
||||||
|
### Identity Representation And Matching
|
||||||
|
|
||||||
|
- Normalize identity field sets once: deduplicate and sort exact field names.
|
||||||
|
Use the same normalizer for YAML and application registration.
|
||||||
|
- Encode a record's identity as a hashable, type-tagged, length-delimited tuple in
|
||||||
|
normalized field order. Mapping lookup indexes avoid scanning record fields
|
||||||
|
repeatedly. Distinguish structural-payload lookup from final typed-payload and
|
||||||
|
parent-reference lookup: only fields acting as directives are excluded, not
|
||||||
|
every occurrence of the same literal spelling. Never include tuple encodings
|
||||||
|
in diagnostics.
|
||||||
|
- Normalize finite JSON numbers to sign, significant decimal digits, and a signed
|
||||||
|
base-10 exponent. Strip insignificant zeroes and give all zero spellings one
|
||||||
|
representation. Use `math/big.Int` for exponent arithmetic, not a power-of-ten
|
||||||
|
expansion or a float conversion. Thus large exponent spellings do not allocate
|
||||||
|
storage proportional to their mathematical magnitude. This comparison-only
|
||||||
|
representation does not rewrite document nodes or typed projection.
|
||||||
|
- Compile selectors into a mapping-segment trie with literal and wildcard edges.
|
||||||
|
Carry applicable trie states through traversal; stop at sequence-index edges.
|
||||||
|
Index concrete matches within one assembly/inheritance scope where useful, but
|
||||||
|
do not save an assembly-only destination list for final validation.
|
||||||
|
- Check registration overlap pairwise once during definition validation using
|
||||||
|
equal length and compatible corresponding segments. This straightforward
|
||||||
|
`O(R² × D)` check, for R registrations and maximum selector depth D, is
|
||||||
|
proportionate for registration definitions. Equivalent constraints coalesce;
|
||||||
|
conflicting overlaps fail even with no matching input.
|
||||||
|
Do not impose a registration-count limit to simplify this operation.
|
||||||
|
|
||||||
|
### Policy And Evidence Storage
|
||||||
|
|
||||||
|
Keep application constraints separate from YAML policy. Add private engine
|
||||||
|
collection-policy records containing normalized identity, explicit mode, and
|
||||||
|
declaration locations. Track whether identity is pending or belongs to a retained
|
||||||
|
collection. Retain declaration presence separately from inferred defaults.
|
||||||
|
|
||||||
|
Use the existing acquisition/item scopes as ownership anchors, with an opaque
|
||||||
|
per-load owner token when a retained record needs one. Store item-local policies
|
||||||
|
relative to their owning record, and project concrete paths when that record is
|
||||||
|
placed. Do not key ownership solely by numeric index, identity tuple, or source
|
||||||
|
filename: all three can coincide for a replacement that must lose old policies.
|
||||||
|
Inheritance clones the relevant scoped state; it does not create registrations.
|
||||||
|
|
||||||
|
Resolve policies through shared engine helpers used by strict composition,
|
||||||
|
precedence, and inheritance. Do not attach engine policy to public values or add
|
||||||
|
policy fields to the parser-independent `document.Node`. The sidecar can be
|
||||||
|
extended with concrete-path indexes; it need not become a general event store.
|
||||||
|
|
||||||
|
Sequence combination returns the resulting list plus explicit input-to-output
|
||||||
|
item projections and retained/replaced contribution information. Use that same
|
||||||
|
result for values, policy transfer, origins, source destinations, sensitivity,
|
||||||
|
and historical replay. Extend the existing sequence-history descriptors for
|
||||||
|
noncontiguous upsert positions rather than inferring them from equal values.
|
||||||
|
Sharing immutable internal evidence is acceptable under existing ownership rules;
|
||||||
|
independently returned mutable records must remain detached.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
Use existing `options.invalid` for invalid application definitions and existing
|
||||||
|
`compose.invalid` / `compose.conflict` for malformed or duplicate strict-peer
|
||||||
|
contributions. Add root-owned and engine diagnostic constants
|
||||||
|
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||||
|
(`sequence.conflict`) for malformed collection declarations/operations or final
|
||||||
|
identities, and conflicting policy / precedence duplicates respectively.
|
||||||
|
Final duplicate identities use `sequence.conflict`. Keep existing inheritance
|
||||||
|
invalid/missing/cycle codes for record relationships. Use existing override and
|
||||||
|
schema/decode codes at the CLI boundary; do not invent a new error taxonomy there.
|
||||||
|
|
||||||
|
Report collection/item paths and available locations, not identity values,
|
||||||
|
normalized tuple strings, raw parent references, or raw hook errors. Declaration
|
||||||
|
conflicts include both sites. Definition overlap errors identify registration
|
||||||
|
positions without fabricating a concrete matched configuration path. Exact
|
||||||
|
English diagnostic wording is not a new guarantee.
|
||||||
|
|
||||||
|
## Stage 1 — Implement Shared Scalar Identity Primitives
|
||||||
|
|
||||||
|
Scope: new private identity helpers and their focused tests; no load-path change.
|
||||||
|
|
||||||
|
1. Add `internal/engine/sequence_identity.go`. Implement field-set normalization,
|
||||||
|
scalar tuple construction, and exact decimal normalization as chosen above.
|
||||||
|
Accept string, number, boolean, and explicit null nodes; distinguish categories
|
||||||
|
and absence. Exclude fields acting as structural directives from payload lookup.
|
||||||
|
2. Validate mapping records, complete direct-field sets, and uniqueness using one
|
||||||
|
tuple-to-first-record index. Return paths and source locations, not values.
|
||||||
|
Support caller-selected diagnostic families for strict and other boundaries.
|
||||||
|
3. Cover equivalent field sets; empty strings and null; missing/non-scalar fields;
|
||||||
|
literal punctuation; compound tuple boundaries; `1`/`1.0`/`1e0`; signed zero;
|
||||||
|
close unequal decimals; and large exponents without expansion. Verify that
|
||||||
|
node text remains unchanged. Include `extends: null` as a structural field
|
||||||
|
that cannot supply source identity.
|
||||||
|
|
||||||
|
Validation: `go test ./internal/engine -run 'SequenceIdentity|SequenceNumber'`
|
||||||
|
and the everyday check. Retain the old registration path until its adapters are
|
||||||
|
replaced; do not generalize unrelated YAML scalar decoding.
|
||||||
|
|
||||||
|
## Stage 2 — Compile Exact And Dynamic Application Constraints
|
||||||
|
|
||||||
|
Scope: private registration model, selector matching, overlap validation.
|
||||||
|
|
||||||
|
1. Extend `sequence_registration.go` with the common registration representation
|
||||||
|
and compiled selector trie. Represent literal and arbitrary-mapping-key segments
|
||||||
|
structurally, not with a magic string. Snapshot registration inputs.
|
||||||
|
2. Validate field sets and mapping-only selectors before source acquisition.
|
||||||
|
Preserve existing destination validation without adding restrictions on identity
|
||||||
|
field spelling. Root and unused selectors remain harmless.
|
||||||
|
3. Implement full-destination matching, equivalent overlap coalescing, and
|
||||||
|
incompatible-overlap errors. Exact rules have no priority over dynamic rules.
|
||||||
|
Keep a temporary conversion from the current single-field registration so
|
||||||
|
existing callers still use their current behavior.
|
||||||
|
4. Test exact and wildcard matches, multiple wildcard levels, empty/punctuation
|
||||||
|
keys, sequence-index nonmatches, disjoint lengths/literals, equivalent sets,
|
||||||
|
incompatible overlaps with absent input, and independent uniqueness scopes.
|
||||||
|
|
||||||
|
Validation: focused registration/selector tests and the everyday check. This
|
||||||
|
stage does not export the replacement API or add final-result validation yet.
|
||||||
|
|
||||||
|
## Stage 3 — Parse Collection Declarations Without Filesystem Coupling
|
||||||
|
|
||||||
|
Scope: private parser and directive extraction helpers, initially unit-tested
|
||||||
|
without activating collection combination in public loads. Keep source-form
|
||||||
|
activation out of this stage; ordinary sources and selected overlays are wired
|
||||||
|
in Stages 13 and 14 respectively.
|
||||||
|
|
||||||
|
1. Add `sequence_declaration.go` for source-root `configkit.sequences`. Each entry
|
||||||
|
has a required existing literal `destination` form and optional `key` and
|
||||||
|
`mode`. `key` is a string or nonempty string list; `mode` is exactly replace,
|
||||||
|
append, or upsert. Use shared identity normalization. A destination-only entry
|
||||||
|
can be a harmless no-op; do not add an unrequested ban on empty declarations.
|
||||||
|
2. Split common root-directive extraction from import-specific parsing in
|
||||||
|
`import_spec.go` / `parse.go`. Extract imports and sequences together, retaining
|
||||||
|
source-local locations and explicit presence. Reject unrecognized directive
|
||||||
|
fields and wrong supplied field types using existing strict parsing style.
|
||||||
|
3. Keep declaration destinations relative to source placement, with item-local
|
||||||
|
coordinates retained until their scope is assembled. Do not project them
|
||||||
|
prematurely to a guessed numeric index.
|
||||||
|
4. Give extraction an explicit overlay context: sequences are allowed, imports
|
||||||
|
are not. Import enablement remains separately enforced; do not turn on
|
||||||
|
filesystem access because a source declares sequences. Keep enabled
|
||||||
|
source-version checks in raw source coordinates before consuming controls;
|
||||||
|
extend existing actual header/control-overlap handling to sequence directives
|
||||||
|
without treating an absent header beneath a control as an actual overlap.
|
||||||
|
5. Test key-only, mode-only, combined, repeated-field-set equivalence, no-op,
|
||||||
|
malformed types/modes/destinations, mixed imports/sequences, and overlay rules.
|
||||||
|
Do not add import-level key/mode shorthand or sequence wrappers.
|
||||||
|
|
||||||
|
Validation: focused declaration/parser tests, existing import-spec tests, and the
|
||||||
|
everyday check. Existing import-only behavior remains intact during preparation.
|
||||||
|
|
||||||
|
## Stage 4 — Reconcile Declarations And Select Collection Operations
|
||||||
|
|
||||||
|
Scope: pure agreement and operation-selection helpers, independent of loading
|
||||||
|
and of the ownership transformations introduced next.
|
||||||
|
|
||||||
|
1. Add `collection_policy.go`. Gather the current scope's declarations before
|
||||||
|
combining peers. Reconcile equivalent keys and agreeing explicit modes;
|
||||||
|
conflicting peers fail. Omitted fields have no preference. Apply authoritative
|
||||||
|
application constraints even for key-only declarations without a value.
|
||||||
|
2. Implement shared operation selection: current explicit mode, retained explicit
|
||||||
|
mode, inferred upsert with effective identity, otherwise replace. Persist only
|
||||||
|
explicit modes. Policy-only declarations must not replay earlier combinations.
|
||||||
|
3. Accept eligible lower policy as an input; do not decide record survival or
|
||||||
|
ancestor removal inside declaration reconciliation. Retain declaration
|
||||||
|
locations/presence separately from inferred mode and application constraints.
|
||||||
|
4. Test equivalent/different field sets, agreeing/conflicting peer modes,
|
||||||
|
omission, explicit default modes, and operation inference. Include key-only
|
||||||
|
declarations agreeing or conflicting with application constraints before a
|
||||||
|
sequence exists.
|
||||||
|
|
||||||
|
Validation: focused policy-agreement/selection tests and the everyday check.
|
||||||
|
Do not activate collection combination or implement the ownership layer here.
|
||||||
|
|
||||||
|
## Stage 5 — Track Policy Lifetime And Record Ownership
|
||||||
|
|
||||||
|
Scope: private scoped-state transformations built on Stage 4's resolution helpers.
|
||||||
|
|
||||||
|
1. Implement key agreement across retained values, replacement reset, pending
|
||||||
|
key declarations, and immediate validation when a key-only declaration
|
||||||
|
identifies retained records.
|
||||||
|
Changing mode to replace without a replacement value does not reset identity.
|
||||||
|
2. Implement scope/record ownership transfer and retirement hooks. Actual ancestor
|
||||||
|
replacement removes descendant collection identity and removed-record policy;
|
||||||
|
independently owned modes and application constraints survive as specified in
|
||||||
|
the roadmap. Do not repeatedly reapply old declarations and resurrect reset
|
||||||
|
identity. Pending declarations retain their owning-scope rules.
|
||||||
|
3. Use item/acquisition scope anchors and record-owned relative policy as selected
|
||||||
|
above. Provide transformations for retention, whole-record replacement,
|
||||||
|
whole-sequence replacement, and inheritance copies without wiring them into
|
||||||
|
public loading yet. Application registrations remain destination constraints.
|
||||||
|
4. Use table-driven tests derived from the roadmap's lifetime, persistence, and
|
||||||
|
replacement tables, including absent/non-sequence destinations, equal outer
|
||||||
|
identities with distinct record owners, and independent root versus item policy.
|
||||||
|
|
||||||
|
Validation: focused collection-policy tests and the everyday check. This stage
|
||||||
|
must not add path-existence, shape, nullability, or global YAML final-check rules.
|
||||||
|
|
||||||
|
## Stage 6 — Implement The Shared Sequence Operations
|
||||||
|
|
||||||
|
Scope: a pure document-level combination helper, not separate implementations
|
||||||
|
for overlays and inheritance.
|
||||||
|
|
||||||
|
1. Add `sequence_combine.go`, accepting lower/higher sequence nodes, resolved
|
||||||
|
policy, and combination context. Reuse Stage 1 identity extraction and indexes.
|
||||||
|
2. Implement replace, duplicate-rejecting append, and whole-record upsert. Upsert
|
||||||
|
retains matched lower positions and appends new higher identities in order.
|
||||||
|
Return item projections and replacement/retention evidence with the new value.
|
||||||
|
3. Keep peer validation distinct: duplicate identities within any contribution or
|
||||||
|
among peers fail under every mode. Do not merge record payloads or sort by ID.
|
||||||
|
4. Cover absent versus null/non-sequence lower values; higher omission, `[]`, null,
|
||||||
|
and non-sequence values; missing identity under append/upsert; empty inputs;
|
||||||
|
stable ordering; equivalent/different keys; and explicit replacement identity
|
||||||
|
reset. An empty input does not bypass kind checks or identity requirements.
|
||||||
|
5. Verify clones do not alias mutable input nodes or policy slices. Build indexes
|
||||||
|
once per operation rather than scanning the accumulated list for every record.
|
||||||
|
|
||||||
|
Validation: focused combination tests and the everyday check. Do not change the
|
||||||
|
ordinary recursive mapping rule or add unkeyed append.
|
||||||
|
|
||||||
|
## Stage 7 — Extend Sequence History Representation And Replay
|
||||||
|
|
||||||
|
Scope: represent and replay sequence operations without yet changing all metadata
|
||||||
|
projection consumers or public precedence.
|
||||||
|
|
||||||
|
1. Extend `sequenceHistoryContribution`, sequence replay in `history.go`, and
|
||||||
|
its contribution indexes to consume Stage 6's explicit item mappings. Existing
|
||||||
|
append ranges may remain a compact case; upsert cannot be represented by one
|
||||||
|
offset.
|
||||||
|
2. Preserve authored raw contribution snapshots and displaced values. A replaced
|
||||||
|
record's omitted fields must appear as removed, not as retained lower payload.
|
||||||
|
Preserve equal-valued replacement events. Keep existing index-based query and
|
||||||
|
atomic sequence-diff APIs; add no public identity-selector path syntax.
|
||||||
|
3. Exercise synthetic append/upsert/replace histories, noncontiguous matches,
|
||||||
|
nested paths, and discarded descendants. Compare replayed final values with
|
||||||
|
the actual combination result, including first/last and unchanged neighboring
|
||||||
|
records. Reuse existing escaped-path and detached-query tests rather than
|
||||||
|
duplicating them.
|
||||||
|
|
||||||
|
Validation: sequence/history/replay tests and the everyday check. Keep current
|
||||||
|
append/replace callers working through the extended descriptors.
|
||||||
|
|
||||||
|
## Stage 8 — Project Sequence Origins And Inspection Evidence
|
||||||
|
|
||||||
|
Scope: use the prepared item mappings consistently in metadata consumers.
|
||||||
|
|
||||||
|
1. Project composition origins, nested source destinations, historical paths, and
|
||||||
|
sensitivity through Stage 6's item mapping and Stage 7's history descriptors.
|
||||||
|
Physical source IDs/locations and resource contexts continue to identify the
|
||||||
|
declaring source. Reuse one projection mechanism rather than searching again
|
||||||
|
by identity or equal payload.
|
||||||
|
2. Test synthetic noncontiguous upserts, replaced descendants, nested source
|
||||||
|
destinations, unchanged neighbors, and secret-marked replaced records. Verify
|
||||||
|
projected queries agree with replayed and actual final values, retaining
|
||||||
|
displaced evidence and detached results.
|
||||||
|
3. Keep the new precedence operations unactivated until their value, policy, and
|
||||||
|
projection machinery are integrated in the later load-path stages.
|
||||||
|
|
||||||
|
Validation: metadata/history/sequence/provenance/sensitivity tests,
|
||||||
|
`go test -race ./internal/engine`, and the everyday check.
|
||||||
|
|
||||||
|
## Stage 9 — Separate Per-Input Acquisition From Composition
|
||||||
|
|
||||||
|
Scope: a behavior-preserving acquired-input handoff; grouped underlay coordination
|
||||||
|
is the next stage.
|
||||||
|
|
||||||
|
1. In `load_input.go`, `imports.go`, and related graph helpers, retain acquisition
|
||||||
|
scopes, raw contributions, inclusion edges, source records,
|
||||||
|
contexts, and extracted declarations without irrevocably composing them.
|
||||||
|
Reuse existing `deferAssembly` and acquired-scope mechanisms instead of adding
|
||||||
|
a second loader. Keep already acquired document handles closed as before.
|
||||||
|
2. Separate acquisition from the per-input composition entry. Retain pending
|
||||||
|
item/helper scope boundaries, namespace identities, inclusion/repetition checks,
|
||||||
|
source records, accounting, and scheduler inputs. Do not reacquire sources to
|
||||||
|
compose them later or change grouped coordination yet.
|
||||||
|
3. Preserve existing version checks for this refactor. Retain a small adapter
|
||||||
|
invoking current composition semantics at the existing lifecycle point.
|
||||||
|
Preserve source-version callbacks at acquisition, including header consumption.
|
||||||
|
4. Run existing acquisition, dependency scheduling, item placement, source-version,
|
||||||
|
ordinary/layered equivalence, resource-lifetime, and source-context tests. Add
|
||||||
|
only targeted regressions for any newly split handoff not already covered.
|
||||||
|
|
||||||
|
Validation: the focused suites above and the everyday check. This refactor must
|
||||||
|
not change source IDs or turn acquisition order into precedence order.
|
||||||
|
|
||||||
|
## Stage 10 — Separate Underlay-Group Acquisition From Composition
|
||||||
|
|
||||||
|
Scope: behavior-preserving group coordination in `load_request.go`, using the
|
||||||
|
per-input handoff already prepared in Stage 9.
|
||||||
|
|
||||||
|
1. Separate acquiring an underlay group from scheduling/composing its acquired
|
||||||
|
scopes. Preserve namespace reanchoring, shared inclusion/repetition state,
|
||||||
|
item-local helper scopes, source/context aggregation, and group accounting.
|
||||||
|
2. Keep authored acquisition order: main and depth-first imports, each underlay
|
||||||
|
and input, overlay, then synthetic CLI records. Preserve underlay indexes and
|
||||||
|
current version-check timing in this refactor; policy-dependent layered
|
||||||
|
composition is activated only in Stage 15.
|
||||||
|
3. Expose the common scope-composition handoff with inputs for accumulated lower
|
||||||
|
policy and compiled application constraints. Initially adapt it to the current
|
||||||
|
semantics. Do not add another loader or repeat acquisition/version callbacks.
|
||||||
|
4. Test the split with existing grouped/dependency/version/destination coverage,
|
||||||
|
including empty inputs/groups and repeated sources within versus across tiers.
|
||||||
|
Confirm unchanged values, source ordering, contexts, and failure evidence.
|
||||||
|
|
||||||
|
Validation: grouped/layered, import scheduling, version, destination, and
|
||||||
|
resource-context tests plus the everyday check.
|
||||||
|
|
||||||
|
## Stage 11 — Compose Strict Collections With Resolved Identity
|
||||||
|
|
||||||
|
Scope: the policy-aware strict composition core. Nested pending-item scope
|
||||||
|
integration is deferred to Stage 12, not absorbed into this prompt.
|
||||||
|
|
||||||
|
1. Extend `composeDocumentsWithPolicy` and `validateRegisteredSequences` to use
|
||||||
|
normalized field sets and scalar tuples. Resolve current declarations,
|
||||||
|
applicable application constraints, and eligible lower identity before peer
|
||||||
|
composition, using the Stage 4 agreement and Stage 5 lifetime helpers.
|
||||||
|
2. Use the existing dependency-ready order for contributions and authored list
|
||||||
|
order. Validate every identified sequence, including singleton contributions;
|
||||||
|
strictly append distinct peers and reject duplicates under all modes.
|
||||||
|
3. For explicit replacement, do not use obsolete lower YAML identity to authorize
|
||||||
|
otherwise unkeyed peer composition. Still apply independently registered
|
||||||
|
application constraints and genuinely applicable declarations.
|
||||||
|
4. Attach Stages 7–8 evidence and Stage 5 scoped policy to composed results. Test
|
||||||
|
completed-document contributions with lower-established identity, declaration
|
||||||
|
conflicts, compound/scalar IDs, singleton validation, and replacement resets.
|
||||||
|
|
||||||
|
Validation: composition and sequence identity tests plus the everyday check.
|
||||||
|
Keep unregistered non-mapping conflicts unchanged. Public load activation remains
|
||||||
|
in the later integration stages.
|
||||||
|
|
||||||
|
## Stage 12 — Integrate Policy-Aware Item And Helper Scopes
|
||||||
|
|
||||||
|
Scope: adapt pending-item/helper composition to Stage 11's core using the acquired
|
||||||
|
scope boundaries from Stages 9–10.
|
||||||
|
|
||||||
|
1. Gather declarations before composing each relevant scope, complete item-local
|
||||||
|
helper imports before item placement, and consume applicable non-main version
|
||||||
|
headers before checking record payload identity. Keep relative policy with its
|
||||||
|
owner until concrete placement is known.
|
||||||
|
2. Apply eligible lower identity at each scope, but prevent a replacement outer
|
||||||
|
record from borrowing nested identity owned by the discarded lower record.
|
||||||
|
Preserve independently applicable policy and application constraints.
|
||||||
|
3. Retained structural directives cannot serve as identity fields. A helper may
|
||||||
|
supply payload identity components. Do not require a helper-built compound
|
||||||
|
parent reference to be complete at this point; validate the completed surviving
|
||||||
|
reference during inheritance.
|
||||||
|
4. Project item evidence and owned policies through Stages 7–8 machinery. Test
|
||||||
|
nested items, helper-supplied identity, header consumption, declaration scope,
|
||||||
|
discarded owner identity, and exact diagnostic paths with no value disclosure.
|
||||||
|
|
||||||
|
Validation: item/destination/import/composition/source-version tests and the
|
||||||
|
everyday check. Keep current public behavior until load-path activation.
|
||||||
|
|
||||||
|
## Stage 13 — Activate Ordinary Source Collection Declarations
|
||||||
|
|
||||||
|
Scope: ordinary `Load` base-source assembly, excluding activation of the new
|
||||||
|
selected-overlay policy surface until Stage 14.
|
||||||
|
|
||||||
|
1. Wire common directive extraction into every ordinary source form: file,
|
||||||
|
native filesystem, bytes, reader, mapping, imported mapping, and sequence item.
|
||||||
|
Declarations work without `Imports`; disabled imports retain their existing
|
||||||
|
rejection semantics. `NoSource` remains absence rather than an empty document.
|
||||||
|
2. Use Stages 11–12 composition and Stage 5 scoped policy. Strip collection
|
||||||
|
controls from application decoding, but retain their locations/evidence for
|
||||||
|
their structural lifetime and diagnostics.
|
||||||
|
3. Integrate policy/projections together with the values. Cover inline/imported
|
||||||
|
equivalence, placement-relative declarations, singleton and peer validation,
|
||||||
|
and declarations without accompanying values. Maintain ordinary loading's
|
||||||
|
existing non-collection overlay behavior while preparing its policy handoff.
|
||||||
|
4. Update the implemented declaration/composition contract sections. Collection
|
||||||
|
precedence, inheritance, and later CLI behavior remain incomplete until their
|
||||||
|
integration stages; do not claim them implemented here.
|
||||||
|
|
||||||
|
Validation: ordinary-load, import, composition, and metadata tests plus
|
||||||
|
the everyday check. Include one public ordinary-load declaration example test.
|
||||||
|
|
||||||
|
## Stage 14 — Activate Selected Overlay Collection Policies
|
||||||
|
|
||||||
|
Scope: ordinary loading's selected overlay and shared precedence integration,
|
||||||
|
using the source/declaration handoff already active in Stage 13.
|
||||||
|
|
||||||
|
1. Permit `configkit.sequences` in selected overlays while continuing to reject
|
||||||
|
overlay imports. Preserve source-version header/control handling and ordinary
|
||||||
|
overlay namespace selection. Consume controls before application decoding.
|
||||||
|
2. Use Stage 6's sequence operations and Stages 4–5 policy helpers in the assembly
|
||||||
|
accumulator. Mappings still recurse and unrelated sequences still replace.
|
||||||
|
Wire value, policy retirement/transfer, and Stages 7–8 inspection projections
|
||||||
|
together, not as a later best-effort repair pass.
|
||||||
|
3. Cover append/upsert/replace, empty/null/kind cases, key-only/mode-only changes,
|
||||||
|
retained explicit modes, overlay identity agreement, and independently owned
|
||||||
|
policies. Check values, history, origins, and discarded descendants together.
|
||||||
|
4. Update the selected-overlay and collection-operation contract sections. Make
|
||||||
|
the shared precedence entry reusable by layered loading without adding another
|
||||||
|
operation implementation or turning overlay imports on.
|
||||||
|
|
||||||
|
Validation: ordinary-load, overlay, composition, policy, and metadata tests plus
|
||||||
|
the everyday check.
|
||||||
|
|
||||||
|
## Stage 15 — Integrate Lower Policy Across Layered Assembly
|
||||||
|
|
||||||
|
Scope: source-backed underlay coordination and the layered selected-overlay path,
|
||||||
|
using the prepared acquisition, ownership, strict-scope, and precedence machinery.
|
||||||
|
Do not absorb unfinished work from Stages 5–14 into this stage.
|
||||||
|
|
||||||
|
1. Acquire main first, but assemble underlays from lowest to highest, strictly
|
||||||
|
composing each group with applicable accumulated lower policy before applying
|
||||||
|
its one precedence operation. Then compose/apply main and the selected overlay.
|
||||||
|
Do not sequentially upsert peer sources as if they were different tiers.
|
||||||
|
2. Pass lower identity into higher strict scopes before peer composition. Cover an
|
||||||
|
otherwise empty underlay establishing identity, inherited explicit mode, and
|
||||||
|
replacement that deliberately discards the old YAML key. Invalid lower tiers
|
||||||
|
still fail even if a higher replacement would hide their values.
|
||||||
|
3. Keep main-only version ownership and every enabled per-source version rule.
|
||||||
|
In default source-version mode, run the main checker once on the composed
|
||||||
|
main tier before overlay/inheritance/defaults, never on underlay-augmented
|
||||||
|
values. Its former main-before-underlay composition timing need not persist:
|
||||||
|
main composition now depends on lower policy. Enabled-mode source checks keep
|
||||||
|
acquisition order and exact source-local header handling. No extra callbacks
|
||||||
|
or second acquisition pass are permitted.
|
||||||
|
4. Keep histories in precedence order, sources in acquisition order, underlay
|
||||||
|
indexes stable across empty groups, and shared budgets intact. Update the
|
||||||
|
layered contract's acquisition-versus-composition description accordingly.
|
||||||
|
Remove the acquisition/composition adapters superseded by this integration;
|
||||||
|
the old public-registration adapter remains until Stage 22.
|
||||||
|
5. Cover multiple inputs/groups, mounted main inputs, item scopes, empty main,
|
||||||
|
explicit empty groups, layered overlays, version protection and diagnostics,
|
||||||
|
and equivalence with ordinary loading where the inputs express the same model.
|
||||||
|
|
||||||
|
Validation: layered/grouped, source-version, dependency scheduling, destination,
|
||||||
|
and metadata tests plus the everyday check.
|
||||||
|
|
||||||
|
## Stage 16 — Verify Replacement And Nested Policy Ownership End To End
|
||||||
|
|
||||||
|
Scope: the consequential lifetime cases across the now-active source assembly.
|
||||||
|
|
||||||
|
1. Exercise an item-local nested collection through retaining the outer record,
|
||||||
|
upserting the same outer ID, and replacing the entire outer sequence. Retained
|
||||||
|
items keep their local key/mode; replacements do not inherit discarded policy
|
||||||
|
merely because identity, source label, or final index is equal.
|
||||||
|
2. Exercise ancestor replacement and later reintroduction. Removed collection
|
||||||
|
identity does not reappear; independently declared append mode can survive
|
||||||
|
and require renewed identity. Cover renewed YAML key, application constraint,
|
||||||
|
explicit replace, and the intended failure with no available identity.
|
||||||
|
3. Cover key-only validation of retained records, pending declarations, a no-value
|
||||||
|
replace declaration that cannot authorize key changes, and independent policy
|
||||||
|
whose owning scope survives a record replacement.
|
||||||
|
4. Correct any integration defects in the Stage 5 ownership transformations;
|
||||||
|
consolidate tests with their mechanism owners. Do not introduce special-case
|
||||||
|
ownership by numeric path or a historical identity cache to make cases pass.
|
||||||
|
5. Verify frozen metadata, origin/resource context, sensitivity of discarded
|
||||||
|
records, and mutable independence across repeated loads for these scenarios.
|
||||||
|
|
||||||
|
Validation: focused policy/sequence/layered/ownership tests,
|
||||||
|
`go test -race ./internal/engine`, and the everyday check.
|
||||||
|
|
||||||
|
## Stage 17 — Prepare Inheritance Policy And Template Transport
|
||||||
|
|
||||||
|
Scope: behavior-preserving inheritance state transport, before activating
|
||||||
|
collection-aware inheritance operations in Stage 18.
|
||||||
|
|
||||||
|
1. Extend inheritance templates/expansion sidecars to carry collection identity
|
||||||
|
and record-local policy alongside nodes and origins. Keep existing scheduler
|
||||||
|
keys, cancellation, version protection, missing-parent/cycle diagnostics, and
|
||||||
|
destination-local evaluation of nested templates.
|
||||||
|
2. Provide owned-state copy/transfer helpers from Stage 5 and path projections from
|
||||||
|
Stage 8. Preserve original nested instructions for each destination's later
|
||||||
|
evaluation rather than retaining only a parent's expanded output. Keep
|
||||||
|
application registration matching separate from copied YAML policy.
|
||||||
|
3. Test template reuse at multiple destinations, independent scoped state, and
|
||||||
|
retained instruction locations/resource context. Preserve current inheritance
|
||||||
|
values/history while these new helpers remain preparatory; do not activate a
|
||||||
|
value change without its corresponding policy/evidence handling.
|
||||||
|
|
||||||
|
Validation: existing inheritance, ownership, history, and resource-context tests,
|
||||||
|
focused transport tests, and the everyday check.
|
||||||
|
|
||||||
|
## Stage 18 — Apply Collection Policy During Mapping-Sibling Inheritance
|
||||||
|
|
||||||
|
Scope: activate shared collection operations using the prepared template/state
|
||||||
|
handoff; existing mapping-sibling relationship selection remains unchanged.
|
||||||
|
|
||||||
|
1. Use the shared sequence combiner at the inheriting destination: parent lower,
|
||||||
|
child higher, destination explicit mode or identity-based default. Copy YAML
|
||||||
|
identity where applicable, but do not automatically copy a parent collection's
|
||||||
|
explicit mode to a different destination. Match application constraints at the
|
||||||
|
destination, including newly created matching paths.
|
||||||
|
2. Transfer record-owned state only with surviving records using Stage 17's
|
||||||
|
helpers. Integrate inheritance evidence with Stage 8's projections in the same
|
||||||
|
activation, preserving origins, historical sensitivity, resource context,
|
||||||
|
cancellation, and existing expansion/history accounting.
|
||||||
|
3. Cover nested append/upsert/replace, explicit empty/null/kind cases, copied key
|
||||||
|
with inferred upsert, destination explicit mode, incompatible keys, application
|
||||||
|
constraints, cancellation, and policy at an inherited new namespace.
|
||||||
|
4. Update current mapping-sibling inheritance documentation for collection
|
||||||
|
operations. Identified-record parent lookup remains incomplete until Stage 21.
|
||||||
|
|
||||||
|
Validation: inheritance, history, provenance, sensitivity, and resource-context
|
||||||
|
tests plus the everyday check. No identified-record parent lookup is active yet.
|
||||||
|
|
||||||
|
## Stage 19 — Build Identified-Record Inheritance Graphs
|
||||||
|
|
||||||
|
Scope: relationship extraction/validation and shared scheduling for sequence
|
||||||
|
scopes; test these helpers before activating payload expansion.
|
||||||
|
|
||||||
|
1. Extend sibling-scope discovery to identified sequences. Build the tuple-to-item
|
||||||
|
lookup once using effective identity. Every record supplies its own complete
|
||||||
|
identity before inheritance; neither parent lookup nor typed defaults supplies
|
||||||
|
a missing child field.
|
||||||
|
2. Parse a surviving record's `extends` as null cancellation or a mapping with
|
||||||
|
exactly the normalized identity fields and supported scalar components.
|
||||||
|
Mapping-sibling references keep their existing string form. Do not accept a
|
||||||
|
scalar shorthand, multiple parents, positional or cross-collection lookup.
|
||||||
|
3. Parent-reference mappings have already used ordinary strict mapping
|
||||||
|
composition: disjoint helper fields can complete one reference, while equal
|
||||||
|
overlapping fields conflict. Validate completeness/extra fields only now on
|
||||||
|
the surviving reference. Discarded references remain historical evidence.
|
||||||
|
4. Build one-parent dependency edges and reuse `scheduleDependencies` with stable
|
||||||
|
semantic-pointer keys. Missing parents, self-reference, and longer cycles
|
||||||
|
use existing inheritance diagnostics without identity values. Keep authored
|
||||||
|
output positions independent from graph schedule.
|
||||||
|
5. Cover compound and null references, numeric equality, forward edges, chains,
|
||||||
|
unrelated equal identities in different lists, malformed refs, missing parents,
|
||||||
|
cycles, helper-built references, and overwritten/cancelled relationships.
|
||||||
|
|
||||||
|
Validation: focused inheritance graph/reference tests and the everyday check.
|
||||||
|
|
||||||
|
## Stage 20 — Prepare Reusable Inheritance Scope Execution
|
||||||
|
|
||||||
|
Scope: remove mapping-container assumptions from the private template executor
|
||||||
|
without activating identified-record inheritance in public loads.
|
||||||
|
|
||||||
|
1. Refactor template execution to separate relationship scheduling/payload
|
||||||
|
expansion from reading and rebuilding its scope container. Preserve current
|
||||||
|
mapping-sibling output, locations, cancellation, evidence, and budgets.
|
||||||
|
2. Provide private mapping- and identified-sequence-scope adapters using Stage 19's
|
||||||
|
relationship graphs. The sequence adapter preserves output positions and
|
||||||
|
exposes child identity fields to the payload-expansion step. Keep this private;
|
||||||
|
do not add a public generic inheritance or merge callback API.
|
||||||
|
3. Reuse Stage 17's template/policy transport and Stage 18's collection-aware
|
||||||
|
payload operations. Existing origin/history/retained-node handling must remain
|
||||||
|
available to both adapters rather than duplicating an entire executor.
|
||||||
|
4. Run existing mapping-inheritance regressions and privately exercise a small
|
||||||
|
sequence graph through the adapter. Check output order, independent templates,
|
||||||
|
and operation/evidence paths before public activation in Stage 21.
|
||||||
|
|
||||||
|
Validation: inheritance execution, scheduling, history, and budget tests plus
|
||||||
|
the everyday check. This refactor does not introduce new parent-reference forms
|
||||||
|
or change public loading semantics yet.
|
||||||
|
|
||||||
|
## Stage 21 — Resolve Record Inheritance With Ownership And Evidence
|
||||||
|
|
||||||
|
Scope: integrate Stage 19's graphs and Stage 20's executor into the structural
|
||||||
|
expansion phase, with values and inspection working together.
|
||||||
|
|
||||||
|
1. Expand parent payload into the child while preserving the child's own identity
|
||||||
|
fields and their origin/presence. Exclude parent identity fields from payload
|
||||||
|
inheritance. Recursively combine mappings and use destination collection policy
|
||||||
|
for nested sequences; retain whole-record upsert at the earlier boundary.
|
||||||
|
2. Consume the child's directive before decoding. Keep parents as ordinary records
|
||||||
|
subject to the same schema/semantic validation; create no abstract templates or
|
||||||
|
acquired source records.
|
||||||
|
3. Resolve only records surviving tier assembly. Cover parent upsert/removal,
|
||||||
|
replacement of a child without `extends`, self-reference not exposing a lower
|
||||||
|
predecessor, and nested destination-local relationships. Enclosing scopes
|
||||||
|
finish before nested scopes; assembled list order stays unchanged.
|
||||||
|
4. Carry policy, origin/history, sensitivity, resource context, and existing
|
||||||
|
expansion/retained-history accounting through copies. Do not widen budgets or
|
||||||
|
require a new inspection-path API.
|
||||||
|
5. Add public-load regressions for complete inheritance chains and nested
|
||||||
|
collections, including secret canaries on success and failure. Update the
|
||||||
|
implemented inheritance contract and consumer guidance for this scope.
|
||||||
|
|
||||||
|
Validation: all inheritance and cross-feature inspection tests,
|
||||||
|
`go test -race ./internal/engine`, and the everyday check.
|
||||||
|
|
||||||
|
## Stage 22 — Expose The Root-Owned Registration API
|
||||||
|
|
||||||
|
Scope: the settled facade surface and migration of in-repository callers.
|
||||||
|
|
||||||
|
1. Implement the exact public names/signatures above in a focused root file, and
|
||||||
|
add `Definition.SequenceIdentities`. Convert root records/selectors explicitly
|
||||||
|
into the engine model. Snapshot fields, selectors, and paths on construction
|
||||||
|
and loading, preserving immutable definition reuse.
|
||||||
|
2. Remove `SequenceComposition`, `Definition.SequenceCompositions`, and the private
|
||||||
|
old-shape adapters. Migrate all maintained code/tests/examples; do not edit
|
||||||
|
historical release notes solely to erase the old name or add deprecated aliases.
|
||||||
|
3. Cover mixed exact/dynamic constructors, compound scalar fields, literal `*`
|
||||||
|
versus wildcard segments, root/unused selectors, invalid index paths and empty
|
||||||
|
field sets, overlap failure before source access, and independent loads.
|
||||||
|
4. Prove dynamic constraints apply before strict composition and at inherited
|
||||||
|
destinations, without consumer namespace enumeration. Keep final-result
|
||||||
|
enforcement described as pending until Stage 27 actually adds it.
|
||||||
|
5. Verify the public boundary and declaration documentation. Do not expose engine
|
||||||
|
enums, policy stores, parser nodes, or internal methods.
|
||||||
|
|
||||||
|
Validation: root sequence/facade, registration, public/dependency-boundary, and
|
||||||
|
consumer example tests plus the everyday check.
|
||||||
|
|
||||||
|
## Stage 23 — Separate Reusable CLI Override Preparation
|
||||||
|
|
||||||
|
Scope: mechanical preparation refactor and a privately tested post-structural
|
||||||
|
validation entry, without changing current CLI processing order.
|
||||||
|
|
||||||
|
1. Refactor override preparation so labels, paths, snapshots, type support,
|
||||||
|
disjointness, version protection, and existing budgets remain reusable outside
|
||||||
|
the source assembly accumulator. Allocate synthetic sources once in the same
|
||||||
|
order; do not reopen or reinterpret CLI values as YAML.
|
||||||
|
2. Prepare post-structural validation that rejects `extends` as well as
|
||||||
|
`configkit` in supplied paths and nested values, using their established
|
||||||
|
structural roles. Test this entry privately; retain the existing entry's
|
||||||
|
behavior until the lifecycle switches in Stage 26. Do not reject previously
|
||||||
|
valid early-CLI instructions before that switch or add unrelated restrictions.
|
||||||
|
3. Test detached prepared batches, synthetic source ordering, and the new
|
||||||
|
directive validation. Reuse existing label/path/cycle/type-support/budget tests
|
||||||
|
rather than adding another inventory of the same preparation cases.
|
||||||
|
|
||||||
|
Validation: override preparation/source tests and the everyday check. Keep
|
||||||
|
ordinary/layered behavior unchanged; typed assignment is the next stage.
|
||||||
|
|
||||||
|
## Stage 24 — Implement Schema-Aware Post-Decode CLI Assignment
|
||||||
|
|
||||||
|
Scope: a private typed assignment helper using Stage 23's prepared overrides,
|
||||||
|
without activating the later lifecycle order yet.
|
||||||
|
|
||||||
|
1. Add `applyDecodedOverrides`, using existing schema/decode mechanisms to bind
|
||||||
|
only supplied override subtrees against a detached copy of the decoded state.
|
||||||
|
Mappings recursively overlay, sequences replace, and explicit zero/empty/null
|
||||||
|
retain normal schema rules. Current typed values supply retained children,
|
||||||
|
not new defaults for replaced list items. Do not call `Definition.Defaults`
|
||||||
|
again or re-decode the whole projected configuration as newly authored input.
|
||||||
|
2. Return the updated typed value, canonical post-CLI projection, presence/origin
|
||||||
|
changes, and existing raw CLI history contributions. Preserve a pre-CLI typed
|
||||||
|
projection for decoding reconciliation/history. No resolver runs on a failed
|
||||||
|
override; no partially applied result is returned.
|
||||||
|
3. Test unknown/type-invalid fields, mapping retention, sequence replacement,
|
||||||
|
nested maps/lists, named/text/duration/secret schema fields, explicit null/false/
|
||||||
|
zero, discarded children, structural controls, and mutable ownership.
|
||||||
|
|
||||||
|
Validation: override, decode, schema, projection, and ownership tests plus the
|
||||||
|
everyday check. Only the helper is added here; the shared lifecycle does not
|
||||||
|
switch until its inspection/context support is prepared in Stage 25.
|
||||||
|
|
||||||
|
## Stage 25 — Prepare Post-Decode CLI Inspection And Effective Context
|
||||||
|
|
||||||
|
Scope: prepare history, resolution-input, and secret-lookup context handling;
|
||||||
|
retain the existing public CLI lifecycle until Stage 26.
|
||||||
|
|
||||||
|
1. Extend existing replay/reconciliation to represent the target order:
|
||||||
|
source tiers, inheritance, typed decoding/defaults, CLI, application resolution.
|
||||||
|
Keep the structural input, pre-CLI typed snapshot, raw CLI contributions, and
|
||||||
|
post-CLI projection distinct where needed. Use the existing history machinery,
|
||||||
|
not a second replay framework, and retain legacy input compatibility until the
|
||||||
|
lifecycle switch. Do not mislabel CLI changes as defaults or inheritance.
|
||||||
|
2. Prepare resolution-entry metadata from Stage 24's result so explicit presence,
|
||||||
|
origins, source records, and resource contexts describe the overridden state.
|
||||||
|
3. Prepare the effective reference/location handoff for secret lookup and final
|
||||||
|
validation using the post-override/post-resolution values, not stale structural
|
||||||
|
input. Preserve existing no-credential-byte and ownership behavior.
|
||||||
|
4. Test the target pipeline with privately assembled snapshots: equal-value CLI
|
||||||
|
replacement, replaced descendants, decoding reconciliation before CLI, later
|
||||||
|
recorded resolver transformations, explicit-presence defaults, and an overridden
|
||||||
|
secret reference. Verify current public-load regressions still pass unchanged.
|
||||||
|
|
||||||
|
Validation: history/metadata/resolution/secret tests and the everyday check.
|
||||||
|
Public activation must not require a later stage to repair these mechanisms.
|
||||||
|
|
||||||
|
## Stage 26 — Activate CLI After Decoding In The Shared Lifecycle
|
||||||
|
|
||||||
|
Scope: activate ADR 0018 through both loading paths using Stages 23–25's complete
|
||||||
|
assignment, inspection, and effective-value handoffs.
|
||||||
|
|
||||||
|
1. Remove CLI assignment from ordinary/layered structural assembly. Invoke
|
||||||
|
Stage 24 after inheritance and successful typed-default creation/decoding,
|
||||||
|
immediately before application resolution. Activate Stage 23's post-structural
|
||||||
|
validation. Keep the single shared lifecycle, not separate CLI implementations.
|
||||||
|
2. Wire Stage 25's history and context handoffs in the same change. Resolution
|
||||||
|
receives explicit-presence/origin metadata and effective CLI values;
|
||||||
|
`Resolution.Default` preserves them while recorded transformations can change
|
||||||
|
them normally. Secret lookup uses effective references and locations. Preserve
|
||||||
|
exactly-once default-factory behavior and synthetic source ordering.
|
||||||
|
3. Cover `NoSource`, ordinary/layered equivalence, CLI clearing/replacing an
|
||||||
|
identified list despite append/upsert mode, derived values seeing CLI input,
|
||||||
|
equal-value provenance, and resolver changes after CLI. Add explicit regressions
|
||||||
|
that CLI cannot fix earlier decode/inheritance errors or introduce/cancel a
|
||||||
|
relationship. Update obsolete early-CLI tests deliberately.
|
||||||
|
4. Update the lifecycle, override, history, and secret-reference contracts and
|
||||||
|
affected examples to describe the now-implemented order.
|
||||||
|
5. Remove the superseded early-CLI application paths, old preparation entry, and
|
||||||
|
temporary replay compatibility paths. Keep their still-relevant behavioral
|
||||||
|
tests under the new lifecycle rather than postponing code cleanup to the
|
||||||
|
documentation stage.
|
||||||
|
|
||||||
|
Validation: override/lifecycle/resolution/inheritance/history/secret tests,
|
||||||
|
`go test -race ./...`, and the everyday check.
|
||||||
|
|
||||||
|
## Stage 27 — Enforce Final Application Identity Constraints
|
||||||
|
|
||||||
|
Scope: the final-result guarantee, not another combination or inheritance phase.
|
||||||
|
|
||||||
|
1. Add shared final identity validation after effective secret lookup and before
|
||||||
|
application semantic validation/publication in the common lifecycle. Use the
|
||||||
|
canonical effective typed projection, which retains secret references rather
|
||||||
|
than credential bytes. Preserve the existing validated publication snapshot
|
||||||
|
and detachment behavior.
|
||||||
|
2. Traverse the actual final tree with the compiled selector trie, validating each
|
||||||
|
matching sequence independently with Stage 1 primitives. This must discover
|
||||||
|
paths introduced by typed defaults, CLI, and callbacks, not only assembly paths.
|
||||||
|
Support root/namespaced rules and unrelated unselected sequences.
|
||||||
|
3. Validate actual sequences only. Missing paths, null, and other shapes remain
|
||||||
|
the schema/application validator's responsibility. Identity extraction here
|
||||||
|
examines effective typed payload, not whether a field was authored. Do not
|
||||||
|
recheck YAML-only policies or every intermediate callback state.
|
||||||
|
4. Cover duplicate/missing/non-scalar identities produced at each late phase,
|
||||||
|
valid null/scalar identities, numeric equality after typed conversion, dynamic
|
||||||
|
namespaces created by each late phase, harmless unmatched rules, concrete-path
|
||||||
|
diagnostics with available origins, and secret-value non-disclosure.
|
||||||
|
5. Prove both `Load` and `LoadLayered` use this guarantee and return no usable
|
||||||
|
result on failure. Update registration comments and canonical contract with
|
||||||
|
this final boundary, excluding later caller mutations from the guarantee.
|
||||||
|
|
||||||
|
Validation: final identity, selector, facade, defaults/resolution/override, and
|
||||||
|
secret tests plus the everyday check.
|
||||||
|
|
||||||
|
## Stage 28 — Exercise Representative Consumer Workflows
|
||||||
|
|
||||||
|
Scope: a small number of meaningful integrations, not a Cartesian test matrix.
|
||||||
|
|
||||||
|
1. Extend `examples/consumers` with an executable source-backed catalog: underlay
|
||||||
|
records, main fragments, selected overlay append/upsert/replace, and explicit
|
||||||
|
record inheritance. Demonstrate complete replacement of a matched record,
|
||||||
|
stable order, and final inspection without application merge code.
|
||||||
|
2. Add a namespaced typed-map example using one wildcard registration plus an
|
||||||
|
exact root registration, with independent uniqueness. Show YAML-declared
|
||||||
|
identity without registration separately so the final guarantee is not implied.
|
||||||
|
3. Include one integrated CLI/resolution case and one representative provenance/
|
||||||
|
resource-context/secret-history case where those interactions add protection
|
||||||
|
beyond mechanism tests. Keep application domain validation recognizable.
|
||||||
|
4. Audit coverage against the roadmap's work inventory, especially helper-built
|
||||||
|
parent references, version-header consumption, pending declarations, lower
|
||||||
|
identity before higher peers, replacement ownership, and dynamic destinations
|
||||||
|
introduced through inheritance and late typed changes. Add only missing,
|
||||||
|
consequential regressions at their narrowest stable owners.
|
||||||
|
5. Consolidate obsolete/brittle tests. Do not restore old replacement semantics
|
||||||
|
merely to keep fixtures unchanged, and do not preserve duplicates of the same
|
||||||
|
semantic table across every layer.
|
||||||
|
|
||||||
|
Validation: `go test ./examples/consumers`, focused root integration tests,
|
||||||
|
`go test -race ./...`, and the everyday check.
|
||||||
|
|
||||||
|
## Stage 29 — Reconcile Documentation
|
||||||
|
|
||||||
|
Scope: finish the current-contract narrative. Code cleanup belongs with the
|
||||||
|
integrations that supersede each adapter, not in this documentation prompt.
|
||||||
|
|
||||||
|
1. Reconcile `docs/config.md` for declarations, identity equality, selectors,
|
||||||
|
operations/persistence, ownership, inheritance, final validation, and CLI order.
|
||||||
|
Keep its operation and presence tables authoritative; Go comments own exported
|
||||||
|
symbols and consumer guidance links rather than copying full contracts.
|
||||||
|
2. Update `docs/consumers/README.md`, relevant root examples, `README.md`,
|
||||||
|
`docs/internal/overview.md`, `docs/internal/imports.md`, and
|
||||||
|
`docs/development.md` where the implemented structure or reading/test map has
|
||||||
|
changed. Remove stale claims about string-only registration, universal sequence
|
||||||
|
replacement, early CLI, and overlay prohibition of all directives.
|
||||||
|
3. Remove implemented portions from the deferred identified-record discussion in
|
||||||
|
`future.md`, retaining genuinely deferred recursive body merging and other
|
||||||
|
still-relevant ideas. Preserve accepted ADR decision content; update only status
|
||||||
|
or implementation references where appropriate. Architecture already adopts
|
||||||
|
the decisions: remove pending-implementation wording when true, without adding
|
||||||
|
new policy. Leave release version selection/publication to the release task.
|
||||||
|
4. Search maintained prose/examples for obsolete registration names and lifecycle
|
||||||
|
claims; retain intentional historical references. Follow canonical ownership
|
||||||
|
rather than copying the complete contract into each guide.
|
||||||
|
|
||||||
|
Validation: local documentation-link checks and whitespace review; run relevant
|
||||||
|
example tests if executable examples changed. Reuse Stage 28's successful
|
||||||
|
code/race validation when its covered content is unchanged.
|
||||||
|
|
||||||
|
## Stage 30 — Audit Scaling And Verify The Completed Target
|
||||||
|
|
||||||
|
Scope: a bounded implementation review and final validation, not another broad
|
||||||
|
refactor or expansion of the feature/performance scope.
|
||||||
|
|
||||||
|
1. Inspect the completed code for duplicate combination/identity algorithms,
|
||||||
|
per-record scans of whole collections, repeated declaration gathering, numeric
|
||||||
|
magnitude expansion, stale ownership/path caches, and unnecessary copies.
|
||||||
|
Prefer shared indexed mechanisms without pooling/global caches. Reuse existing
|
||||||
|
composition/underlay/inheritance benchmarks and add a focused mixed-upsert or
|
||||||
|
wildcard-collection benchmark only where existing fixtures cannot exercise the
|
||||||
|
new scaling risk. Record commands and qualitative findings here; timing or
|
||||||
|
allocation thresholds and a quiet-host benchmark campaign are not gates.
|
||||||
|
2. Confirm transitional adapters/dead early-CLI paths have been removed. Search
|
||||||
|
current code and maintained examples for `SequenceCompositions` and
|
||||||
|
`SequenceComposition`; only intentional historical references may remain.
|
||||||
|
Confirm no new dependencies, public internal-type exposure, background work,
|
||||||
|
release machinery, or additional limits were introduced. Review the full diff
|
||||||
|
against the roadmap, not merely passing tests. Record bounded review findings
|
||||||
|
here; significant corrective
|
||||||
|
work requires its own focused unit rather than being hidden inside this stage.
|
||||||
|
Do not declare completion while a target behavior remains incorrect or missing.
|
||||||
|
|
||||||
|
Validation: `./scripts/check.sh`, `go test -race ./...`, and the development
|
||||||
|
guide's local documentation/public-boundary checks, reusing successful checks
|
||||||
|
from Stages 28–29 if their covered content is unchanged. Record completed scope,
|
||||||
|
validation, benchmark commands/qualitative findings, and any real remaining
|
||||||
|
issues accurately. No new timing thresholds or release gates are introduced.
|
||||||
|
|
||||||
|
Acceptance: all roadmap operations and lifecycle boundaries work through ordinary
|
||||||
|
and layered loads; values and inspection agree; application constraints hold on
|
||||||
|
successful final results; deferred behavior and YAML-only guarantees have not
|
||||||
|
expanded. All thirty stages are complete and their nonempty units committed.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. Feature choices are settled in the roadmap and accepted ADRs; this plan
|
||||||
|
selects the remaining API, representation, validation, and execution decisions.
|
||||||
999
docs/roadmap/sequences.md
Normal file
999
docs/roadmap/sequences.md
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
# Sequence Identity And Combination Roadmap
|
||||||
|
|
||||||
|
Status: settled feature roadmap; implementation is specified in the
|
||||||
|
[staged implementation plan](implementation.md). This document defines the
|
||||||
|
targeted end state, not an implemented contract. The implementation plan owns
|
||||||
|
execution order, exact Go names and signatures, bookkeeping, and validation.
|
||||||
|
|
||||||
|
## Purpose And Scope
|
||||||
|
|
||||||
|
Allow configuration authors to assemble collections from independently authored
|
||||||
|
records, declare identity in configuration, and progressively extend a baseline
|
||||||
|
collection through higher layers. Authors can replace the entire collection,
|
||||||
|
append distinct records, or replace complete records by identity while retaining
|
||||||
|
unmatched records. Sibling inheritance uses the same sequence operations and
|
||||||
|
identity-based defaults. Within an identified sequence, a record can explicitly
|
||||||
|
inherit settings from another record in that sequence while keeping its own
|
||||||
|
identity.
|
||||||
|
|
||||||
|
The motivating use case is a prompt catalog whose source-backed baseline supplies
|
||||||
|
records and whose application or installation layers add or supersede records.
|
||||||
|
Applications can optionally impose identity constraints that configuration must
|
||||||
|
respect and that hold in the final loaded configuration. These constraints can
|
||||||
|
cover exact destinations or collections beneath dynamically named mapping keys,
|
||||||
|
without requiring the application to discover those names before loading.
|
||||||
|
|
||||||
|
The feature covers ordinary and layered loading, imported items, inline
|
||||||
|
sequences, selected overlays, and sibling inheritance. Typed defaults, CLI
|
||||||
|
assignments, and application resolution retain their own combination semantics;
|
||||||
|
CLI application moves later in the loading lifecycle.
|
||||||
|
|
||||||
|
This scope is narrower than the deferred
|
||||||
|
[identified-record sequences proposal](future.md#1-identified-record-sequences).
|
||||||
|
Automatic recursive merging of matching records remains outside this scope;
|
||||||
|
explicit inheritance between distinct records is included. The initial
|
||||||
|
declaration surface is `configkit.sequences`; `key` and `mode` shorthand on
|
||||||
|
imports is deferred. Application lists remain
|
||||||
|
ordinary YAML sequences without control wrappers. Unkeyed sequence concatenation
|
||||||
|
is outside this feature.
|
||||||
|
|
||||||
|
## Current Baseline And Required Changes
|
||||||
|
|
||||||
|
The [import contract](../config.md#explicit-imports) owns existing selectors,
|
||||||
|
destinations, placement, and ordering. `placement: sequence-item` already places
|
||||||
|
each selected source mapping as one item at its effective destination. Item-local
|
||||||
|
helper imports can contribute fields before placement.
|
||||||
|
|
||||||
|
The [composition contract](../config.md#strict-document-composition),
|
||||||
|
[layered-loading contract](../config.md#layered-loading), and
|
||||||
|
[inheritance contract](../config.md#sibling-inheritance) describe the implemented
|
||||||
|
baseline:
|
||||||
|
|
||||||
|
| Area | Current behavior | Target behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Identity declaration | `Definition.SequenceCompositions` registers one literal string field at each registered mapping path. | YAML collection declarations and optional authoritative application registrations support field sets and scalar identity components. |
|
||||||
|
| Application registration destinations | Registrations select exact literal mapping paths. | Exact and structural selectors can impose identity beneath arbitrary mapping keys, independently at each matched destination. |
|
||||||
|
| Strict composition | Registered sequences append distinct string identities and reject duplicates; repeated unregistered sequences conflict. | Collections with effective identity append distinct records and reject duplicates. Applicable lower-tier identity is available before composing higher-tier peers. Unidentified sequences retain strict-conflict behavior. |
|
||||||
|
| Source precedence and selected overlays | A present higher sequence replaces the lower sequence wholesale. | Collection policy selects `replace`, `append`, or `upsert`; overlays can declare policy for inline sequences. |
|
||||||
|
| Sibling inheritance | An inheriting mapping recursively overlays its resolved parent; supplied sequences replace wholesale. | Mappings retain recursive combination, while sequences use destination policy and the same three operations. |
|
||||||
|
| Sequence-member inheritance | A sequence element has no sibling scope for its own inheritance relationship. | An identified sequence provides a sibling scope: a record can inherit from one record in the same sequence, selected by complete identity. |
|
||||||
|
| CLI overrides | Apply before inheritance and typed defaults/decoding. | Apply after inheritance and typed defaults/decoding, before application resolution. |
|
||||||
|
| Identity enforcement | Registration governs strict sequence composition. | Structural identity checks cover identified combinations; application registrations additionally constrain final effective sequences. |
|
||||||
|
|
||||||
|
Source-backed underlays participate in source assembly. `Definition.Defaults`
|
||||||
|
provides typed defaults later; extending source combination does not turn typed
|
||||||
|
default lists into source underlays.
|
||||||
|
|
||||||
|
## Collection Declarations
|
||||||
|
|
||||||
|
Use a source-root `configkit.sequences` block containing collection declarations.
|
||||||
|
Each entry identifies a `destination` and may declare `key`, `mode`, or both.
|
||||||
|
Identity and mode can be supplied independently. This form serves imported items,
|
||||||
|
inline records, ordinary mapping imports, and inline sequences in selected
|
||||||
|
overlays.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: [namespace, id]
|
||||||
|
mode: append
|
||||||
|
imports:
|
||||||
|
- dir: prompts
|
||||||
|
pattern: '*.yaml'
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
```
|
||||||
|
|
||||||
|
Each selected file contributes a record such as:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
namespace: support
|
||||||
|
id: welcome
|
||||||
|
text: Welcome
|
||||||
|
```
|
||||||
|
|
||||||
|
Here append retains a lower-layer collection and adds distinct higher-layer
|
||||||
|
records. Identity and combination controls belong to the collection declaration,
|
||||||
|
so imported and inline contributions share one policy surface.
|
||||||
|
|
||||||
|
### Destinations And Scope
|
||||||
|
|
||||||
|
`destination` uses the existing sequence of literal mapping keys. Resolve it
|
||||||
|
relative to the declaring source's placement: a fragment mounted under `catalog`
|
||||||
|
can declare `[prompts]` locally. A sequence-item source can declare a nested
|
||||||
|
collection within its own item without referring to an eventual numeric index.
|
||||||
|
Preserve source and item-local scope boundaries.
|
||||||
|
|
||||||
|
A collection's scope is its resolved destination within its composition scope.
|
||||||
|
Resolve relative destinations before reconciling declarations. Different imported
|
||||||
|
items retain separate scopes for their nested collections. A declaration governs
|
||||||
|
all contributions to its collection in the tier, including imported and inline
|
||||||
|
records; it does not govern only records in the declaring file.
|
||||||
|
|
||||||
|
Gather declarations before composing the relevant scope so their effect does not
|
||||||
|
depend on which contribution is assembled first. Retain applicable controls
|
||||||
|
through source precedence and inheritance, then keep them out of application
|
||||||
|
decoding.
|
||||||
|
|
||||||
|
Sequence declarations are independent of filesystem import enablement. Selected
|
||||||
|
overlays may contain `configkit.sequences`, extending the current blanket
|
||||||
|
prohibition on overlay `configkit` declarations. This does not enable imports
|
||||||
|
in overlays.
|
||||||
|
|
||||||
|
### Overlay Authoring
|
||||||
|
|
||||||
|
For an existing sequence `[foo, bar]`, an overlay can replace `foo` in full:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: upsert
|
||||||
|
prompts:
|
||||||
|
- id: foo
|
||||||
|
text: Revised definition
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `[foo*, bar]`. The explicit upsert mode can be omitted when no
|
||||||
|
different explicit mode applies from a lower tier: effective identity then
|
||||||
|
selects upsert. To add a distinct record instead:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: append
|
||||||
|
prompts:
|
||||||
|
- id: baz
|
||||||
|
text: Another prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `[foo, bar, baz]`. To replace the whole sequence:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
mode: replace
|
||||||
|
prompts:
|
||||||
|
- id: bar
|
||||||
|
text: Bar definition
|
||||||
|
- id: baz
|
||||||
|
text: Baz definition
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `[bar, baz]`. Explicitly selecting the default operation is valid.
|
||||||
|
With no applicable explicit mode, omitting this declaration selects replace for
|
||||||
|
an unidentified collection and upsert for an identified one. A retained explicit
|
||||||
|
mode continues to govern.
|
||||||
|
|
||||||
|
## Identity Definitions And Constraints
|
||||||
|
|
||||||
|
### Field Sets
|
||||||
|
|
||||||
|
`key` accepts a string or a nonempty list of strings naming direct, literal
|
||||||
|
fields in each record. A name such as `metadata.id` denotes that literal field,
|
||||||
|
not a nested lookup. Treat field names as a set: remove repeated names and
|
||||||
|
ignore authored order when comparing definitions.
|
||||||
|
|
||||||
|
Thus `id`, `[id]`, and `[id, id]` are equivalent; `[namespace, id]`,
|
||||||
|
`[id, namespace]`, and `[namespace, id, id]` are also equivalent. An empty list
|
||||||
|
is invalid because it supplies no identity fields. These rules apply to both
|
||||||
|
configuration declarations and application registrations.
|
||||||
|
|
||||||
|
Use a consistent field order internally to construct identity tuples. Preserve
|
||||||
|
component types and tuple boundaries so unstructured concatenation cannot cause
|
||||||
|
otherwise distinct identities to collide.
|
||||||
|
|
||||||
|
### Scalar Components And Equality
|
||||||
|
|
||||||
|
Each identified record must be a mapping with every designated identity field
|
||||||
|
present. Components may be strings, numbers, booleans, or explicit null. Missing
|
||||||
|
fields and mapping or sequence components are invalid. Present null differs
|
||||||
|
from absence; two single-field null identities are equal. Compound identities
|
||||||
|
are equal when all corresponding components are equal.
|
||||||
|
|
||||||
|
Strings compare exactly, including empty strings, without trimming, case folding,
|
||||||
|
or normalization. Scalar categories remain distinct: `"1"`, `1`, `true`, and
|
||||||
|
null are different identities.
|
||||||
|
|
||||||
|
Numbers compare by mathematical value across integer, fractional, and exponent
|
||||||
|
spellings: `1`, `1.0`, and `1e0` are equal, as are `0` and `-0`. Use exact numeric
|
||||||
|
normalization for identity comparison without rounding through a floating-point
|
||||||
|
conversion. This does not rewrite authored values or change existing typed
|
||||||
|
decoding and projection semantics. The parser's rejection of non-finite numeric
|
||||||
|
forms remains unchanged.
|
||||||
|
|
||||||
|
### Application-Owned Constraints
|
||||||
|
|
||||||
|
Retain an optional application interface for registering collections and imposing
|
||||||
|
their identity definitions. Configuration may define identity where no application
|
||||||
|
constraint exists. When a declaration omits `key`, an applicable registration
|
||||||
|
supplies identity. An explicit equivalent definition is accepted, and a different
|
||||||
|
definition fails under every mode, including replace.
|
||||||
|
|
||||||
|
For example, if the application requires `id`, `key: [id, name]` is invalid:
|
||||||
|
that compound identity would allow repeated `id` values with different names.
|
||||||
|
A registration is an authoritative constraint, not an omission-filling default.
|
||||||
|
Copying a collection through inheritance does not create another application
|
||||||
|
registration; registrations constrain their registered destinations.
|
||||||
|
|
||||||
|
Identity constraints validate actual sequences. They do not independently require
|
||||||
|
a path to exist, be non-null, or have sequence shape. The application schema and
|
||||||
|
validation own those requirements. Final-result enforcement for application
|
||||||
|
registrations is defined under [Validation And Diagnostics](#validation-and-diagnostics).
|
||||||
|
|
||||||
|
### Application Registration API
|
||||||
|
|
||||||
|
Use one public sequence-identity registration type, collected in one field on
|
||||||
|
`Definition`. Each registration contains an explicit destination selector and
|
||||||
|
its identity fields. Exact and dynamic registrations share matching, overlap
|
||||||
|
validation, identity comparison, and lifecycle behavior.
|
||||||
|
|
||||||
|
Provide two constructors returning that same registration type:
|
||||||
|
|
||||||
|
- An exact-path convenience constructor accepting a literal `Path` and identity
|
||||||
|
field names. It converts the path into a selector containing literal segments.
|
||||||
|
- An explicit-selector constructor accepting a structural selector and identity
|
||||||
|
field names, for destinations that include arbitrary mapping keys.
|
||||||
|
|
||||||
|
Both constructors accept identity fields as variadic strings, so one-field and
|
||||||
|
compound identities use the same construction form. Apply the settled field-set
|
||||||
|
rules, including deduplication. Invalid registrations, including an empty identity
|
||||||
|
field set, fail during application-definition validation.
|
||||||
|
|
||||||
|
Existing `Path` values retain their literal meaning throughout ConfigKit. The
|
||||||
|
exact-path constructor supplies convenience at the registration boundary; dynamic
|
||||||
|
matching uses the explicit selector type. Both forms can appear together in the
|
||||||
|
same definition field.
|
||||||
|
|
||||||
|
Replace the current registration shape with this model. Preserving the existing
|
||||||
|
`SequenceComposition` fields and `Definition.SequenceCompositions` spelling is not
|
||||||
|
a requirement, and no compatibility facade is needed solely to retain that syntax.
|
||||||
|
Choose names centered on sequence identity, reflecting its role in composition,
|
||||||
|
inheritance, and final validation. Exact Go type, field, and constructor names
|
||||||
|
and signatures belong in the implementation plan.
|
||||||
|
|
||||||
|
### Dynamic Application Registration Destinations
|
||||||
|
|
||||||
|
Allow applications to register identity for collections whose containing mapping
|
||||||
|
keys become known only during loading. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
collections:
|
||||||
|
weatherreporter:
|
||||||
|
prompts:
|
||||||
|
- id: foo.bar
|
||||||
|
text: Report the weather.
|
||||||
|
homewatch:
|
||||||
|
prompts:
|
||||||
|
- id: foo.bar
|
||||||
|
text: Report the household status.
|
||||||
|
```
|
||||||
|
|
||||||
|
One application registration can require `id` for every namespace's `prompts`
|
||||||
|
list. The equal IDs above are valid because each list has its own uniqueness
|
||||||
|
scope. An optional root `prompts` list can have a separate exact registration.
|
||||||
|
|
||||||
|
Use an explicit application-side structural selector composed of literal mapping
|
||||||
|
keys and an arbitrary-mapping-key segment. Conceptually, the namespace rule is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Literal("collections") -> AnyMappingKey -> Literal("prompts")
|
||||||
|
```
|
||||||
|
|
||||||
|
This notation explains the selector; it is not a Go signature or YAML syntax.
|
||||||
|
Each segment consumes exactly one mapping level. Match the complete destination,
|
||||||
|
not an arbitrary prefix or suffix. Literal keys remain exact, including empty
|
||||||
|
strings and keys containing punctuation such as `*`, `/`, or `.`. An arbitrary-key
|
||||||
|
segment is distinct from any literal spelling and does not match a sequence
|
||||||
|
index. Existing `Path` values retain their literal meaning.
|
||||||
|
|
||||||
|
This capability belongs to the reusable application definition and applies to
|
||||||
|
ordinary and layered loads. YAML collection destinations and import destinations
|
||||||
|
retain their existing literal syntax. Recursive search, regular expressions,
|
||||||
|
filesystem glob rules, sequence-index selectors, and custom matching callbacks
|
||||||
|
are outside this addition.
|
||||||
|
|
||||||
|
At each concrete matched destination, apply the same authoritative identity
|
||||||
|
constraint as an exact registration. Use the settled field-set and scalar-equality
|
||||||
|
rules. Do not pool identities across destinations or require records to repeat
|
||||||
|
the containing mapping key as an identity component. Applications retain ownership
|
||||||
|
of namespace interpretation, qualified runtime IDs, and domain validation.
|
||||||
|
|
||||||
|
Matching supplies effective identity before relevant strict composition and
|
||||||
|
precedence operations. Without an applicable explicit mode, this identity selects
|
||||||
|
upsert under the existing default rule. It also supplies identity for inheritance
|
||||||
|
within the matched sequence. YAML declarations must agree with it under every
|
||||||
|
mode, including replace. These are ordinary application constraints with a broader
|
||||||
|
destination selector, not a separate final-validation facility.
|
||||||
|
|
||||||
|
### Registration Overlap
|
||||||
|
|
||||||
|
Equivalent application constraints coalesce at any shared destination. Equivalence
|
||||||
|
uses the identity field-set rules: for example, `id` and `[id, id]` agree.
|
||||||
|
Incompatible constraints fail; exact registrations do not override broader
|
||||||
|
selectors, and registration order grants no precedence.
|
||||||
|
|
||||||
|
Detect incompatible overlapping registrations while validating the application
|
||||||
|
definition, before source acquisition. For these fixed-depth mapping selectors,
|
||||||
|
overlap exists when the selectors have the same number of segments and every
|
||||||
|
corresponding pair can match a common key. Unequal literal keys at any position
|
||||||
|
make the selectors disjoint; an arbitrary-key segment can overlap a literal or
|
||||||
|
another arbitrary-key segment. Compare segment structure rather than rendered
|
||||||
|
path strings. No configuration pre-read is needed.
|
||||||
|
|
||||||
|
Thus an `id` rule for all namespace prompt lists conflicts with a `[id, name]`
|
||||||
|
rule for one named namespace, even if that namespace is absent from a particular
|
||||||
|
load. This is an invalid application definition. A valid selector matching no
|
||||||
|
collections is harmless: registration does not require a path to exist, create
|
||||||
|
collections, or impose shape and nullability requirements.
|
||||||
|
|
||||||
|
### Matching Across The Loading Lifecycle
|
||||||
|
|
||||||
|
Match application selectors against effective configuration destinations after
|
||||||
|
accounting for input placement. Physical filenames and source-local paths do not
|
||||||
|
determine applicability. Make constraints available before composing contributions
|
||||||
|
at those destinations, including namespaces first introduced by imports, mounted
|
||||||
|
inputs, or higher tiers. Apply matching constraints to key-only YAML declarations
|
||||||
|
as well, even before their sequence exists. Consumers need neither a preliminary
|
||||||
|
load nor source inspection to enumerate namespaces.
|
||||||
|
|
||||||
|
During inheritance, apply registrations at the inheriting destination, including
|
||||||
|
newly created matching destinations. Copying a collection does not transfer its
|
||||||
|
application registrations to a different path. The existing YAML-policy ownership
|
||||||
|
and transfer rules remain separate from application selector matching.
|
||||||
|
|
||||||
|
During final validation, find matching actual sequences in the final effective
|
||||||
|
configuration. This includes namespaces introduced or replaced by typed defaults,
|
||||||
|
CLI overrides, and application resolution. Reusing only destinations found during
|
||||||
|
source assembly would miss these values. Use the same selector meaning and
|
||||||
|
identity validator in both phases; later assignments do not trigger composition
|
||||||
|
or inheritance again. YAML-only identities retain their existing enforcement
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
## Strict Composition And Precedence Operations
|
||||||
|
|
||||||
|
### Compose Peers Before Applying Precedence
|
||||||
|
|
||||||
|
Resolve applicable identity and mode policy, strictly compose the tier's peer
|
||||||
|
contributions, then apply one selected operation per collection against the
|
||||||
|
accumulated lower value. The tier operation does not sequentially replace peer
|
||||||
|
source contributions. Selected overlay contributions use the same combination
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
Within a strict tier, equivalent identity definitions coalesce and different
|
||||||
|
field sets fail. Agreeing explicit modes coalesce; conflicting explicit modes
|
||||||
|
fail. Omission expresses no preference. A root declaration or a later declaration
|
||||||
|
has no precedence over conflicting peers. Explicitly selecting a default mode
|
||||||
|
is still a real instruction and can conflict with another explicit mode.
|
||||||
|
|
||||||
|
Identified peer sequences append distinct records. Duplicate identities among
|
||||||
|
peers or within a contributed list fail under all three modes, including replace
|
||||||
|
and upsert. Equal or disjoint record payloads do not excuse a duplicate identity.
|
||||||
|
Unidentified sequences retain their current strict-composition conflict behavior.
|
||||||
|
|
||||||
|
For example, higher-tier fragments `[C]` and `[D]` with an agreed identity assemble
|
||||||
|
as `[C, D]`. Against lower `[A, B]`, append or upsert produces `[A, B, C, D]`,
|
||||||
|
while replace produces `[C, D]`. Peers `[B]` and `[B*]` conflict even under
|
||||||
|
upsert because neither has higher precedence.
|
||||||
|
|
||||||
|
### Available Operations
|
||||||
|
|
||||||
|
Use one spelling for each mode: `replace`, `append`, and `upsert`.
|
||||||
|
|
||||||
|
| 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, retain unmatched lower records, and append new identities. |
|
||||||
|
|
||||||
|
Append and upsert require effective identity. ConfigKit does not infer identity
|
||||||
|
from an `id` field or record position. Whole-sequence replacement does not require
|
||||||
|
identity agreement with lower YAML policy, but application constraints still apply.
|
||||||
|
|
||||||
|
Letters denote record identities; `B*` has the same identity as `B` with a different
|
||||||
|
payload:
|
||||||
|
|
||||||
|
| Lower | Higher | Mode | Result |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `[A, B]` | `[C]` | `append` | `[A, B, C]` |
|
||||||
|
| `[A, B]` | `[C]` | `replace` | `[C]` |
|
||||||
|
| `[A, B]` | `[B*]` | `append` | Duplicate-identity error |
|
||||||
|
| `[A, B]` | `[B*]` | `replace` | `[B*]` |
|
||||||
|
| `[A, B]` | `[B*]` | `upsert` | `[A, B*]` |
|
||||||
|
| `[A, B]` | `[B*, C]` | `upsert` | `[A, B*, C]` |
|
||||||
|
|
||||||
|
Upsert replaces the whole matched record. If lower `B` has `id`, `text`, and
|
||||||
|
`temperature`, while higher `B` has only `id` and `text`, the result at this
|
||||||
|
boundary has only `id` and `text`. Subsequent defaulting or other lifecycle
|
||||||
|
operations retain their own semantics. Mappings continue to combine recursively;
|
||||||
|
matching sequence records do not recursively merge fields.
|
||||||
|
|
||||||
|
### Mode Selection And Persistence
|
||||||
|
|
||||||
|
Select the operation in this order:
|
||||||
|
|
||||||
|
1. Reconcile explicit modes within the current tier; conflicting peers fail.
|
||||||
|
2. Use the current tier's explicit mode if supplied.
|
||||||
|
3. Otherwise retain an applicable explicit mode from a lower tier.
|
||||||
|
4. With no explicit mode, infer upsert when effective identity is available.
|
||||||
|
5. Otherwise infer replace.
|
||||||
|
|
||||||
|
Effective identity can come from an application registration, current declarations,
|
||||||
|
or applicable lower policy, subject to the identity agreement and lifetime rules
|
||||||
|
below. Determine the inferred mode from available identity before applying the
|
||||||
|
operation. A plain higher-tier sequence can therefore use upsert without repeating
|
||||||
|
a key declaration.
|
||||||
|
|
||||||
|
An explicit mode persists at its applicable destination until a higher-priority
|
||||||
|
explicit declaration changes it. New sequence values alone do not reset it, and
|
||||||
|
an unrelated overlay leaves it intact. A mode-only declaration can change policy
|
||||||
|
without supplying records; it neither clears the collection nor retroactively
|
||||||
|
recombines earlier tiers. Later source contributions and inheritance use the
|
||||||
|
resulting policy.
|
||||||
|
|
||||||
|
| Lower explicit mode | Higher declaration | Effective mode |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `append` | Mode omitted | `append` |
|
||||||
|
| `append` | `upsert` | `upsert` |
|
||||||
|
| `upsert` | `replace` | `replace` |
|
||||||
|
| None | Mode omitted, effective identity available | Inferred `upsert` |
|
||||||
|
| None | Mode omitted, no effective identity | Inferred `replace` |
|
||||||
|
|
||||||
|
Only explicit modes persist. Inferred defaults are recomputed from effective
|
||||||
|
identity when needed. Explicit replace continues to apply in subsequent tiers
|
||||||
|
until another mode is declared; introducing identity alone does not override it.
|
||||||
|
Mode persistence does not preserve YAML identity discarded by replacement, nor
|
||||||
|
policies owned by discarded records. Source precedence and inheritance use the
|
||||||
|
same destination policy, without separate per-phase settings. CLI assignments
|
||||||
|
remain outside mode selection.
|
||||||
|
|
||||||
|
### Presence, Empty Sequences, Null, And Kind Mismatches
|
||||||
|
|
||||||
|
At a collection's precedence boundary:
|
||||||
|
|
||||||
|
| 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 | Ordinary replacement, subject to application schema. | Error: the operation requires a sequence. |
|
||||||
|
|
||||||
|
A declaration alone does not clear a collection. An optional import selecting
|
||||||
|
nothing contributes nothing and does not erase existing records. An absent lower
|
||||||
|
collection can be initialized by an incoming sequence under any mode, subject to
|
||||||
|
identity requirements. Explicit lower null or another non-sequence value is not
|
||||||
|
absence: append and upsert fail against it, while replacement remains available.
|
||||||
|
An empty incoming sequence bypasses neither kind checks nor identity requirements.
|
||||||
|
|
||||||
|
With effective identity and no explicit mode, `prompts: []` uses upsert and does
|
||||||
|
not clear the collection. Clearing requires replace, selected in the current tier
|
||||||
|
or retained from lower policy. Replacing an identified collection with null also
|
||||||
|
requires replace and a schema permitting null. Collection null is distinct from
|
||||||
|
a present null identity component inside a record.
|
||||||
|
|
||||||
|
### Ordering
|
||||||
|
|
||||||
|
Retain existing dependency-ready source scheduling within each tier and authored
|
||||||
|
item order within lists. Gathering declarations does not change assembly order;
|
||||||
|
identity values do not sort records.
|
||||||
|
|
||||||
|
- Append keeps lower records in order, followed by higher records in assembled order.
|
||||||
|
- Replace keeps only higher records in their assembled order.
|
||||||
|
- Upsert keeps a matched record's lower position and appends new identities in
|
||||||
|
higher-tier order.
|
||||||
|
|
||||||
|
For example, lower `[A, B, C]` and higher `[C*, A*, D]` produce `[A*, B, C*, D]`
|
||||||
|
under upsert. Whole-sequence replacement gives the higher tier control of the
|
||||||
|
complete sequence order.
|
||||||
|
|
||||||
|
## Policy Agreement, Lifetime, And Ownership
|
||||||
|
|
||||||
|
### Identity Agreement And Replacement
|
||||||
|
|
||||||
|
Append and upsert use one agreed identity for all participating records:
|
||||||
|
|
||||||
|
| Lower identity | Higher declaration | Result when combining records |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | Omitted | Use `id`. |
|
||||||
|
| `id` | `id`, `[id]`, or `[id, id]` | Accept the equivalent definition. |
|
||||||
|
| `id` | `[namespace, id]` | Error: different definitions. |
|
||||||
|
| None | `id` | Establish `id` and validate all participating records. |
|
||||||
|
|
||||||
|
Introducing identity for an unkeyed lower sequence requires checking both lower
|
||||||
|
and incoming records, including uniqueness within each contribution. With no
|
||||||
|
identity from the application or either participating collection, append and
|
||||||
|
upsert fail.
|
||||||
|
|
||||||
|
YAML identity follows the collection that survives an operation:
|
||||||
|
|
||||||
|
| Operation | Effective YAML identity |
|
||||||
|
| --- | --- |
|
||||||
|
| Append or upsert | Retain or establish the agreed identity. |
|
||||||
|
| Whole-sequence replacement | Discard lower YAML identity and use the replacing tier's identity declaration, if any. |
|
||||||
|
| Replacement by null or another non-sequence value | Discard lower YAML identity and retain any identity explicitly declared by the replacing tier. |
|
||||||
|
|
||||||
|
Application constraints remain authoritative under every operation. Without an
|
||||||
|
application constraint, replacement can introduce a different identity, such as
|
||||||
|
changing from `id` to `name`. Replacing a sequence without a new key declaration
|
||||||
|
or application registration produces an unkeyed sequence. No identity-removal
|
||||||
|
directive is needed.
|
||||||
|
|
||||||
|
The reset occurs only when the collection value is actually replaced. Declaring
|
||||||
|
replace without supplying a replacement does not authorize changing the identity
|
||||||
|
of retained records. Explicit mode lifetime is separate from YAML identity
|
||||||
|
lifetime.
|
||||||
|
|
||||||
|
### Ancestor Replacement
|
||||||
|
|
||||||
|
Replacing an enclosing mapping can remove a collection without replacing that
|
||||||
|
collection directly. Apply the same identity and ownership rules to this removal:
|
||||||
|
discard the removed collection's lower YAML identity, and discard policies owned
|
||||||
|
by any removed records. Reintroducing the same path later does not restore those
|
||||||
|
discarded policies.
|
||||||
|
|
||||||
|
Independently declared explicit modes retain their existing persistence rules.
|
||||||
|
Removing a descendant collection does not remove a mode whose declaring scope
|
||||||
|
survives. Application registrations remain applicable at matching destinations,
|
||||||
|
including destinations reintroduced later. Identity newly declared by the
|
||||||
|
replacing tier follows the existing declaration and ownership rules.
|
||||||
|
|
||||||
|
For example, a root-level declaration establishes `key: id` and `mode: append`
|
||||||
|
for `[catalog, prompts]`. A higher tier replaces `catalog` with null, removing
|
||||||
|
the prompts collection and its YAML identity. The independently declared append
|
||||||
|
mode survives. If a later tier recreates `catalog.prompts`, append requires
|
||||||
|
identity supplied by a new declaration or an application registration. Without
|
||||||
|
that identity, loading fails; the later tier can instead explicitly choose
|
||||||
|
replace. The replacement of `catalog` itself follows ordinary ancestor-value
|
||||||
|
precedence and does not attempt to append null to the removed prompts list.
|
||||||
|
|
||||||
|
### Declarations Without Sequence Values
|
||||||
|
|
||||||
|
A key declaration without accompanying records applies immediately to an existing
|
||||||
|
retained sequence: check agreement and validate its records, without recombining
|
||||||
|
earlier tiers. For example, a higher tier that only declares
|
||||||
|
`destination: [prompts], key: id` identifies and validates the retained lower
|
||||||
|
`prompts` sequence.
|
||||||
|
|
||||||
|
If no collection exists, retain the declaration in its owning scope for later
|
||||||
|
contributions. An otherwise empty underlay can establish identity for main-tier
|
||||||
|
fragments. An explicit identity declaration can also remain pending at a
|
||||||
|
non-sequence destination; it does not independently impose a shape requirement.
|
||||||
|
The presence and kind rules still determine whether an attempted operation fails.
|
||||||
|
Item-local declarations retain their ownership boundaries.
|
||||||
|
|
||||||
|
### Lower-Tier Identity During Strict Composition
|
||||||
|
|
||||||
|
Resolve applicable identity before composing a higher tier's peers, using
|
||||||
|
application registrations, available lower-tier policy, and declarations gathered
|
||||||
|
within the higher tier's scope. Authors need not repeat a baseline key solely
|
||||||
|
because the higher tier has multiple fragments.
|
||||||
|
|
||||||
|
For example, an underlay establishes `key: id` for `prompts`, and two main-tier
|
||||||
|
fragments contribute `[foo]` and `[bar]` without a key declaration. They strictly
|
||||||
|
compose as `[foo, bar]` before the main tier combines with the accumulated lower
|
||||||
|
collection. If both contribute `foo`, composition fails; upsert does not give
|
||||||
|
precedence to peers.
|
||||||
|
|
||||||
|
Lower YAML identity remains subject to agreement, replacement, and ownership.
|
||||||
|
A replacing collection must not be constrained by obsolete lower identity just
|
||||||
|
because policy resolution precedes composition. Likewise, a replacement record
|
||||||
|
cannot borrow nested identity owned by the discarded lower record, even when
|
||||||
|
outer identity or numeric position matches. Application constraints still apply.
|
||||||
|
Higher-tier declarations do not retroactively repair invalid lower-tier composition.
|
||||||
|
|
||||||
|
### Nested Record Ownership
|
||||||
|
|
||||||
|
Attach item-local policies to their owning contributions so they follow surviving
|
||||||
|
records independently of numeric indexes. Whole-record upsert discards the old
|
||||||
|
record's local policies along with its fields. Whole-sequence replacement discards
|
||||||
|
policies owned by removed records. A replacement record supplies its own local
|
||||||
|
declarations; equal outer identity or final index does not transfer discarded
|
||||||
|
policies.
|
||||||
|
|
||||||
|
For example, a source imported as one record into `prompts` can declare:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [steps]
|
||||||
|
key: id
|
||||||
|
mode: append
|
||||||
|
id: foo
|
||||||
|
steps:
|
||||||
|
- id: prepare
|
||||||
|
```
|
||||||
|
|
||||||
|
The declaration governs `steps` inside that `foo` record. Retaining `foo` retains
|
||||||
|
its local policy, including the explicit mode. Upserting a new `foo` discards the
|
||||||
|
old `steps` and its declaration even though the outer record keeps its position.
|
||||||
|
|
||||||
|
Policies independently applicable at a destination are not discarded merely
|
||||||
|
because an individual record is replaced. Ownership determines which declarations
|
||||||
|
are removed. Application registrations remain constraints at their registered
|
||||||
|
destinations. Inheritance copies transfer identity and choose operations as
|
||||||
|
specified next.
|
||||||
|
|
||||||
|
## Sibling Inheritance
|
||||||
|
|
||||||
|
Complete source-tier assembly before resolving inheritance. Retain existing
|
||||||
|
mapping-sibling scopes and add identified-sequence scopes as described below.
|
||||||
|
Both use the existing dependency-ready algorithm. At each inheritance combination,
|
||||||
|
the resolved parent's collection is the lower value and the inheriting mapping's
|
||||||
|
local collection is the higher value. Mappings combine recursively; upsert within
|
||||||
|
an identified sequence remains whole-record replacement. Explicit record
|
||||||
|
inheritance is a separate construction step after those replacements.
|
||||||
|
|
||||||
|
Use the inheriting destination collection's policy: an applicable explicit mode,
|
||||||
|
including one retained through source tiers, wins. Otherwise infer upsert with
|
||||||
|
effective identity and replace without it. There is no separate default-append
|
||||||
|
rule for inheritance. The same destination policy governs source-precedence and
|
||||||
|
inheritance combinations.
|
||||||
|
|
||||||
|
YAML identity follows a collection copied through inheritance, subject to agreement
|
||||||
|
and replacement rules. A source collection's mode does not automatically become an
|
||||||
|
instruction at a different inheriting destination. For example, source `steps`
|
||||||
|
has `key: id` and `mode: append`; an inheriting mapping supplies local `steps`
|
||||||
|
without an applicable explicit mode. The copied identity selects upsert there.
|
||||||
|
An explicit destination append instead selects append. A matching destination key
|
||||||
|
agrees; a different key conflicts when combining records, while explicit replace
|
||||||
|
can establish new YAML identity subject to application constraints.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [parent, items]
|
||||||
|
key: id
|
||||||
|
child:
|
||||||
|
items:
|
||||||
|
- id: foo
|
||||||
|
value: A
|
||||||
|
- id: bar
|
||||||
|
value: B
|
||||||
|
parent:
|
||||||
|
extends: child
|
||||||
|
items:
|
||||||
|
- id: baz
|
||||||
|
value: C
|
||||||
|
```
|
||||||
|
|
||||||
|
Despite the names, `parent` inherits from `child`. Identity at `[parent, items]`
|
||||||
|
selects upsert, producing `[foo, bar, baz]`. If the local sequence instead supplies
|
||||||
|
a revised `foo`, the result is `[foo*, bar]`. Append rejects that repeated identity;
|
||||||
|
replace retains only the local sequence.
|
||||||
|
|
||||||
|
The same ordering, presence, and kind rules apply at inheritance boundaries.
|
||||||
|
An omitted local sequence retains the inherited sequence. Explicit `[]` clears it
|
||||||
|
under replace and contributes no records under append or upsert. Retain controls
|
||||||
|
until inheritance uses them, then exclude them from application decoding.
|
||||||
|
|
||||||
|
### Inheritance Between Identified Sequence Records
|
||||||
|
|
||||||
|
An identified sequence supplies a sibling scope for its records. Identity can
|
||||||
|
come from an application registration or an applicable YAML declaration. A record
|
||||||
|
may inherit from one other record in that same sequence using an `extends`
|
||||||
|
mapping that selects the parent's complete identity:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
prompts:
|
||||||
|
- id: base
|
||||||
|
model: standard
|
||||||
|
temperature: 0.5
|
||||||
|
- id: concise
|
||||||
|
extends: {id: base}
|
||||||
|
temperature: 0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The final `concise` record is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: concise
|
||||||
|
model: standard
|
||||||
|
temperature: 0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The `base` record remains in the collection. Parents are ordinary records subject
|
||||||
|
to the same decoding and validation as other records; there is no abstract
|
||||||
|
template category.
|
||||||
|
|
||||||
|
Use mapping-form references for both single and compound keys. With
|
||||||
|
`key: [namespace, id]`, a reference can be
|
||||||
|
`extends: {namespace: shared, id: base}`. Match components using the collection's
|
||||||
|
field-set and scalar-equality rules, including mathematical numeric equality.
|
||||||
|
`extends: {id: null}` selects a null-valued identity; `extends: null` cancels a
|
||||||
|
relationship.
|
||||||
|
|
||||||
|
Parent-reference mappings use ordinary strict mapping composition. Item-local
|
||||||
|
helper imports may supply disjoint fields of a compound reference; overlapping
|
||||||
|
fields conflict, even when equal. For example, a helper's
|
||||||
|
`extends: {namespace: shared}` and the importing record's `extends: {id: base}`
|
||||||
|
compose into `extends: {namespace: shared, id: base}`. This assembles one record;
|
||||||
|
it does not permit strict peers to contribute duplicate record identities.
|
||||||
|
|
||||||
|
Before parent lookup, require the surviving effective reference mapping to contain
|
||||||
|
exactly the collection's identity fields, with supported scalar values. Missing
|
||||||
|
or extra fields fail. Validate the completed reference during inheritance, rather
|
||||||
|
than requiring each helper contribution to be independently complete. Superseded
|
||||||
|
references remain historical evidence; earlier strict-composition conflicts
|
||||||
|
remain errors.
|
||||||
|
|
||||||
|
Every record must supply its complete own identity before inheritance. Preserve
|
||||||
|
the child's identity fields and inherit only the parent's nonidentity payload;
|
||||||
|
inheritance cannot supply a missing child identity. Child payload fields take
|
||||||
|
precedence, mappings combine recursively, and nested sequences use the inheriting
|
||||||
|
destination's collection policy. Consume the record's directive before decoding.
|
||||||
|
|
||||||
|
Support one parent within the same identified sequence, including forward
|
||||||
|
references and chains. Missing parents and cycles, including self-reference,
|
||||||
|
fail the load. Cross-collection references, position-based references, multiple
|
||||||
|
parents, and inheritance between unidentified sequence members are outside this
|
||||||
|
scope. Existing string-form references between mapping siblings retain their
|
||||||
|
meaning; they do not become shorthand for sequence identity references.
|
||||||
|
|
||||||
|
Resolve relationships against records surviving source-tier assembly and selected
|
||||||
|
overlays. A higher layer can replace a parent before inheritance runs; removing
|
||||||
|
that parent makes a surviving reference fail. Whole-record upsert discards the
|
||||||
|
old record's directive along with its payload. Omitting `extends` in the
|
||||||
|
replacement record does not retain the displaced record's relationship. An
|
||||||
|
explicit reference to the record's own identity is a cycle, not a way to access
|
||||||
|
its lower-tier predecessor.
|
||||||
|
|
||||||
|
Reuse Kahn's algorithm with the existing stable canonical semantic-pointer key
|
||||||
|
for dependency-ready ordering. Build parent lookup from the sequence's effective
|
||||||
|
identity, and preserve the assembled list order independently of resolution
|
||||||
|
order. Enclosing relationships resolve before nested relationships. Parent
|
||||||
|
templates retain nested instructions for evaluation at each inheriting
|
||||||
|
destination, following the existing destination-local inheritance rule. Copied
|
||||||
|
collection identity, destination operation selection, and record-owned policy
|
||||||
|
transfer follow the rules above.
|
||||||
|
|
||||||
|
Record inheritance runs in the existing structural phase, before typed defaults,
|
||||||
|
decoding, CLI overrides, and application resolution. Those later stages do not
|
||||||
|
trigger another inheritance pass. Preserve inherited presence, origin and
|
||||||
|
history evidence, sensitivity, declaring resource contexts, and existing
|
||||||
|
expansion budgets just as for mapping-sibling inheritance. Identity lookup must
|
||||||
|
not expose identity values in diagnostics. Existing index-based inspection paths
|
||||||
|
remain sufficient; no new identity-selector path API is needed.
|
||||||
|
|
||||||
|
## Loading Lifecycle
|
||||||
|
|
||||||
|
Use the following order:
|
||||||
|
|
||||||
|
1. Assemble source tiers and selected overlays, resolving policy before each
|
||||||
|
tier's strict composition and applying collection operations at precedence
|
||||||
|
boundaries.
|
||||||
|
2. Resolve sibling inheritance in mappings and identified sequences, then apply
|
||||||
|
typed defaults and decode the assembled configuration.
|
||||||
|
3. Apply explicit CLI overrides to that configuration and check supplied values
|
||||||
|
against the application schema.
|
||||||
|
4. Run application resolution against the overridden configuration.
|
||||||
|
5. Resolve effective secret references, validate the final configuration including
|
||||||
|
application-registered identities, and publish it.
|
||||||
|
|
||||||
|
### Typed Defaults And Application Resolution
|
||||||
|
|
||||||
|
Source-backed underlays, including application-supplied mapping sources,
|
||||||
|
participate in sequence combination. Typed `Definition.Defaults` remains the
|
||||||
|
initial decoded state: supplied sequences replace default sequences. Source
|
||||||
|
append does not append to a typed default list.
|
||||||
|
|
||||||
|
Application resolution retains assignment and recorded-transformation semantics.
|
||||||
|
ConfigKit does not automatically combine a callback's assigned sequence with its
|
||||||
|
previous value. Application-registered identities receive final validation
|
||||||
|
independently of how defaults, CLI overrides, or callbacks produce their values.
|
||||||
|
|
||||||
|
### CLI Precedence
|
||||||
|
|
||||||
|
CLI overrides are the highest-precedence input to application resolution.
|
||||||
|
Supplied sequences replace whole sequences, including sequences inside supplied
|
||||||
|
mappings. Mappings retain recursive override behavior; empty sequences and
|
||||||
|
permitted nulls retain explicit replacement meaning. Configuration append or
|
||||||
|
upsert does not reinterpret CLI assignments. Inheritance and defaulting do not
|
||||||
|
run again afterward: overriding assembled `[A, B]` with `[C]` presents `[C]` to
|
||||||
|
application resolution without inheritance reintroducing prior records.
|
||||||
|
|
||||||
|
Application resolution sees CLI values and their explicit-presence and source
|
||||||
|
metadata. `Resolution.Default` preserves explicit CLI values; normalization and
|
||||||
|
recorded application transformations may still change them. For example, a derived
|
||||||
|
URL can use a CLI hostname, while an explicit CLI URL can be preserved by an
|
||||||
|
absence-based default. Retain recording and provenance requirements for those
|
||||||
|
transformations.
|
||||||
|
|
||||||
|
Reject structural directives such as `extends` and `configkit` in CLI override
|
||||||
|
paths or supplied values. CLI input supplies application values after structural
|
||||||
|
processing; it cannot supply or cancel inheritance directives, change completed
|
||||||
|
relationships, or prevent earlier inheritance failures.
|
||||||
|
|
||||||
|
Typed decoding also precedes CLI application, so a CLI replacement cannot repair
|
||||||
|
an earlier decoding failure. A source string in an integer field fails before a
|
||||||
|
valid CLI integer can apply. This changes current override-before-decoding
|
||||||
|
behavior. Semantic validation remains after overrides and application resolution.
|
||||||
|
|
||||||
|
Secret lookup uses effective references after overrides and resolution. Neither
|
||||||
|
stage bypasses final application identity constraints or semantic validation.
|
||||||
|
|
||||||
|
## Validation And Diagnostics
|
||||||
|
|
||||||
|
### Structural Identity Checks
|
||||||
|
|
||||||
|
Allow item-local helper imports to complete and consume applicable non-main
|
||||||
|
source-version headers before checking payload identity for collection assembly.
|
||||||
|
A helper can supply an identity component; a consumed header cannot supply one
|
||||||
|
after removal.
|
||||||
|
|
||||||
|
Structural controls are likewise unavailable as source identity components.
|
||||||
|
Check identity against application payload fields, excluding fields acting as
|
||||||
|
structural directives even when the engine retains them for later processing.
|
||||||
|
For example, `key: extends` with an item containing `extends: null` does not
|
||||||
|
supply a null identity: that field cancels inheritance and is consumed. Report
|
||||||
|
the ordinary missing-identity-component error at the collection/item path using
|
||||||
|
available origins, without exposing values. Do not defer this failure until
|
||||||
|
directive consumption or final application validation.
|
||||||
|
|
||||||
|
This rule follows a field's structural role; it does not introduce a broader
|
||||||
|
restriction on unrelated literal field names. Final application identity checks
|
||||||
|
continue to examine the effective typed payload under their existing rules.
|
||||||
|
|
||||||
|
Validate each identified contribution before combination and check uniqueness in
|
||||||
|
the resulting identified collection, including inheritance operations. Upsert
|
||||||
|
matches across precedence are replacements; duplicates within a contribution
|
||||||
|
remain errors. Later inheritance, typed defaults, or callbacks cannot repair an
|
||||||
|
identity missing at an earlier assembly boundary.
|
||||||
|
|
||||||
|
YAML-only identities are enforced during structural composition, precedence
|
||||||
|
assembly, and identified inheritance operations. They have no final-result
|
||||||
|
guarantee after typed defaulting, CLI overrides, or application resolution. Those
|
||||||
|
stages can produce duplicate identities or otherwise violate a YAML-only identity.
|
||||||
|
Applications needing a final guarantee register the constraint through the
|
||||||
|
application-owned interface.
|
||||||
|
|
||||||
|
Adding record inheritance does not expand this guarantee: a YAML declaration
|
||||||
|
can enable parent lookup without imposing final validation on values later
|
||||||
|
produced by defaults, CLI overrides, or application resolution.
|
||||||
|
|
||||||
|
### Final Application Constraints
|
||||||
|
|
||||||
|
On successful loading, final effective collections covered by application
|
||||||
|
registrations satisfy their declared identity constraints. Retain assembly-time
|
||||||
|
checks and validate final effective sequences before publication, after defaults,
|
||||||
|
inheritance, overrides, application resolution, and secret lookup.
|
||||||
|
|
||||||
|
This covers both exact and dynamic application registrations. Evaluate dynamic
|
||||||
|
selectors against the final effective configuration, with independent validation
|
||||||
|
for each concrete matched sequence as described under
|
||||||
|
[Matching Across The Loading Lifecycle](#matching-across-the-loading-lifecycle).
|
||||||
|
|
||||||
|
ConfigKit performs these identity checks; consumers register the constraint
|
||||||
|
without needing to repeat duplicate detection in `Definition.Validate`.
|
||||||
|
Application-owned semantic validation continues to cover domain-specific rules.
|
||||||
|
|
||||||
|
This guarantee covers actual sequences, not path existence or allowed shape.
|
||||||
|
It does not require every intermediate callback state to satisfy the constraint
|
||||||
|
and does not cover caller mutations after loading returns. Final checks examine
|
||||||
|
effective typed values; assembly checks validate authored contributions. A final
|
||||||
|
scalar alone does not prove that a source explicitly supplied that field.
|
||||||
|
|
||||||
|
Use the existing canonical final-value representation and shared publication
|
||||||
|
path as the foundation. The guarantee requires neither a new mutation-history
|
||||||
|
system nor exact causal reconstruction for records supplied by defaults or
|
||||||
|
callbacks.
|
||||||
|
|
||||||
|
### Diagnostics And Inspection
|
||||||
|
|
||||||
|
Identity diagnostics identify collection and item paths using available origin
|
||||||
|
information without exposing identity values. Conflicting declarations identify
|
||||||
|
the collection destination and declaration locations.
|
||||||
|
|
||||||
|
For dynamic registrations, use concrete matched collection and item paths for
|
||||||
|
value diagnostics, with available origins and existing sensitivity rules.
|
||||||
|
Definition-time overlap errors identify the conflicting registrations; they do
|
||||||
|
not require or invent a concrete destination from a loaded document.
|
||||||
|
|
||||||
|
Correctness includes final values, projected item paths, source destinations,
|
||||||
|
provenance, history, sensitivity, and passive resource contexts. Appending can
|
||||||
|
change final indexes; replacing changes which contributions survive. Preserve
|
||||||
|
the existing loading and inspection contracts through both operations, including
|
||||||
|
policy ownership and inheritance transfer.
|
||||||
|
|
||||||
|
## Work Needed From The Baseline
|
||||||
|
|
||||||
|
The baseline needs the following connected capabilities to reach the target
|
||||||
|
state. Execution order and test sequencing belong to the
|
||||||
|
[implementation plan](implementation.md):
|
||||||
|
|
||||||
|
- **Declarations and public API:** extend application registration to the settled
|
||||||
|
identity model with one public registration type and definition field, an
|
||||||
|
exact-path convenience constructor, and an explicit-selector constructor.
|
||||||
|
Preserve literal `Path` semantics. Parse and consume `configkit.sequences` across
|
||||||
|
supported source forms and selected overlays; preserve placement-relative and
|
||||||
|
item-local scopes. Keep sequence declarations independent of filesystem import
|
||||||
|
enablement.
|
||||||
|
- **Identity and policy representation:** implement field-set comparison, typed
|
||||||
|
scalar identity tuples with exact numeric equality, declaration agreement,
|
||||||
|
pending declarations, explicit-mode persistence, replacement lifetime, and
|
||||||
|
record-owned policy transfer. Choose bookkeeping proportional to these semantics.
|
||||||
|
Handle descendant collection removal through ancestor replacement, preserving
|
||||||
|
independently declared modes and application constraints while discarding
|
||||||
|
removed collection identity and record-owned policies.
|
||||||
|
- **Registration matching:** support literal and arbitrary-mapping-key segments,
|
||||||
|
detect incompatible selector overlap during definition validation, and resolve
|
||||||
|
applicable constraints at concrete placed destinations. Reuse the identity
|
||||||
|
rules and validator for exact and dynamic registrations. Keep matching separate
|
||||||
|
from source discovery and from YAML declaration ownership.
|
||||||
|
- **Assembly handoffs:** make accumulated lower policy available before higher-tier
|
||||||
|
strict composition. Separate source acquisition from policy-aware composition
|
||||||
|
or otherwise revise assembly sequencing; current main-before-underlay composition
|
||||||
|
is not a design constraint. Preserve distinctions among acquisition order,
|
||||||
|
dependency-ready scheduling within tiers, and precedence order. Account for
|
||||||
|
source records, callbacks, main-version checks, and failure diagnostics. Retain
|
||||||
|
version ownership and validation semantics; higher replacement cannot hide
|
||||||
|
lower-tier structural failures.
|
||||||
|
- **Combination and inheritance:** implement the three operations with strict peer
|
||||||
|
handling, whole-record replacement, presence rules, stable ordering, and the
|
||||||
|
destination policy rules for inheritance. Extend sibling-scope discovery and
|
||||||
|
parent lookup to identified sequences, validate mapping-form references, and
|
||||||
|
preserve child identities while copying parent payloads. Reuse the shared
|
||||||
|
dependency-ready scheduler and destination-local nested resolution. Preserve
|
||||||
|
provenance, history, sensitivity, paths, and resource contexts as records survive,
|
||||||
|
move, or disappear.
|
||||||
|
- **Lifecycle and validation:** move CLI application after inheritance and typed
|
||||||
|
defaults/decoding, preserve explicit-presence and source metadata for application
|
||||||
|
resolution, reject CLI structural directives, and add final application identity
|
||||||
|
validation through the shared publication path. Keep YAML-only enforcement
|
||||||
|
within structural operations. Match dynamic registrations during assembly and
|
||||||
|
inheritance, and against the final effective configuration rather than a saved
|
||||||
|
list of source-backed destinations.
|
||||||
|
Exclude structural controls from source identity checks even while retaining
|
||||||
|
those controls for later inheritance processing.
|
||||||
|
- **Documentation and verification:** exercise ordinary and layered loading,
|
||||||
|
imported and inline collections, item-local scopes, version-header consumption,
|
||||||
|
selected overlays, inheritance, typed defaults, CLI, callbacks, and inspection.
|
||||||
|
Cover single and compound parent references, scalar equality, forward references
|
||||||
|
and chains, missing parents and cycles, parent replacement/removal, child identity
|
||||||
|
preservation, nested destination policy, cancellation, and stable output order.
|
||||||
|
Verify helper-composed references, overlapping reference fields, missing or extra
|
||||||
|
fields, and replacement of references together with their containing records.
|
||||||
|
Verify independent namespace uniqueness, root and namespaced registrations,
|
||||||
|
literal punctuation keys, equivalent and incompatible overlaps, and conflicts
|
||||||
|
detected even without a matching input destination. Cover namespaces introduced
|
||||||
|
by imports, placement, higher tiers, inheritance, defaults, CLI, and callbacks;
|
||||||
|
preserve ordinary strict-peer and upsert behavior. Check YAML disagreement under
|
||||||
|
replace, harmless absent or empty collections, schema-owned shape requirements,
|
||||||
|
unrelated sequences remaining unselected, and concrete-path diagnostics.
|
||||||
|
Verify both constructors feed the same registration model, including mixed
|
||||||
|
exact and dynamic registrations, compound identities, and definition-time
|
||||||
|
rejection of empty identity field sets.
|
||||||
|
Cover ancestor replacement followed by collection reintroduction, including a
|
||||||
|
surviving append mode with and without renewed identity, explicit replacement,
|
||||||
|
and application-registered identity. Verify that `extends: null` cannot supply
|
||||||
|
an identity component and that structural-control exclusion uses ordinary
|
||||||
|
value-free missing-identity diagnostics.
|
||||||
|
Update the public contract and consumer examples when behavior lands.
|
||||||
|
|
||||||
|
The collection-policy and CLI-ordering decisions are accepted in
|
||||||
|
[ADR 0017](../adr/0017-collection-sequence-policy.md) for collection policy and
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) for CLI ordering.
|
||||||
|
Their scoped supersession is recorded in the affected earlier ADRs, and the
|
||||||
|
[architecture policy](../policy/architecture.md) reflects the accepted decisions.
|
||||||
|
These govern the target architecture; the configuration contract continues to
|
||||||
|
describe implemented behavior until the feature lands. This roadmap does not
|
||||||
|
select migration machinery.
|
||||||
|
|
||||||
|
ADR 0017 now includes inheritance within identified sequences;
|
||||||
|
[ADR 0009](../adr/0009-sibling-inheritance.md) records that scope extension in its
|
||||||
|
status while preserving its decision content. The architecture policy is aligned.
|
||||||
|
Dynamic selectors extend the destinations covered by application registration;
|
||||||
|
they retain the accepted application authority and final-validation boundary.
|
||||||
|
|
||||||
|
No open feature decisions remain. The implementation plan translates this end
|
||||||
|
state into reviewable stages without adding guarantees, restrictions, or
|
||||||
|
combination semantics.
|
||||||
@@ -16,9 +16,10 @@ type PromptRecord struct {
|
|||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Messages []string `yaml:"messages"`
|
Messages []string `yaml:"messages"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadPromptCatalog composes explicitly registered prompt records in the main
|
// LoadPromptCatalog discovers explicitly registered prompt records in the main
|
||||||
// tier and replaces the lower-priority default collection as a whole.
|
// tier and replaces the lower-priority default collection as a whole.
|
||||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||||
return configkit.LoadLayered(configkit.Input{
|
return configkit.LoadLayered(configkit.Input{
|
||||||
@@ -28,16 +29,30 @@ func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig],
|
|||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
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")),
|
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||||
}}}},
|
}}}},
|
||||||
Definition: configkit.Definition[PromptCatalogConfig]{
|
ImportDefaults: configkit.ImportDefaults{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{
|
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||||
Path: configkit.Keys("prompts"),
|
|
||||||
IdentityField: "id",
|
|
||||||
}},
|
|
||||||
Validate: validatePromptCatalog,
|
|
||||||
},
|
},
|
||||||
|
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]{
|
||||||
|
SequenceCompositions: []configkit.SequenceComposition{{
|
||||||
|
Path: configkit.Keys("prompts"),
|
||||||
|
IdentityField: "id",
|
||||||
|
}},
|
||||||
|
Validate: validatePromptCatalog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
||||||
for index, prompt := range configuration.Prompts {
|
for index, prompt := range configuration.Prompts {
|
||||||
if strings.TrimSpace(prompt.Name) == "" {
|
if strings.TrimSpace(prompt.Name) == "" {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package consumers_test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
pathpkg "path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -20,8 +22,26 @@ func Example_identifiedPromptCatalog() {
|
|||||||
for _, prompt := range result.Config.Prompts {
|
for _, prompt := range result.Config.Prompts {
|
||||||
ids = append(ids, prompt.ID)
|
ids = append(ids, prompt.ID)
|
||||||
}
|
}
|
||||||
fmt.Println(ids, err)
|
emptySearches := 0
|
||||||
// Output: [main curated weather welcome] <nil>
|
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) {
|
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||||
@@ -36,7 +56,7 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
if len(result.Config.Prompts) != 4 {
|
if len(result.Config.Prompts) != 4 {
|
||||||
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
||||||
}
|
}
|
||||||
ids := []string{"main", "curated", "weather", "welcome"}
|
ids := []string{"weather", "curated", "welcome", "main"}
|
||||||
for index, id := range ids {
|
for index, id := range ids {
|
||||||
if result.Config.Prompts[index].ID != id {
|
if result.Config.Prompts[index].ID != id {
|
||||||
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
||||||
@@ -46,12 +66,15 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
if prompt.ID == "default" {
|
if prompt.ID == "default" {
|
||||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
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
|
var itemImports, helperImports int
|
||||||
for _, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
switch filepath.Base(source.Path) {
|
switch filepath.Base(source.Path) {
|
||||||
case "curated.yml", "generated-weather.yml", "generated-welcome.yml":
|
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||||
t.Fatalf("item source placement = %#v", source)
|
t.Fatalf("item source placement = %#v", source)
|
||||||
}
|
}
|
||||||
@@ -71,6 +94,36 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
||||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: prompts/curated.yml
|
- dir: prompts
|
||||||
|
exclude: [generated-ignored.yml]
|
||||||
destination: [prompts]
|
destination: [prompts]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
- glob: prompts/generated-*.yml
|
- dir: featured
|
||||||
|
pattern: [weather-*.yml]
|
||||||
destination: [prompts]
|
destination: [prompts]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
|
- dir: optional
|
||||||
prompts:
|
prompts:
|
||||||
- id: main
|
- id: main
|
||||||
name: Main authored prompt
|
name: Main authored prompt
|
||||||
|
|||||||
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: curated-id.yml
|
- path: helpers/curated-id.yml
|
||||||
name: Curated summary
|
name: Curated summary
|
||||||
messages:
|
messages:
|
||||||
- Summarize the supplied content.
|
- 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.]
|
||||||
@@ -2,3 +2,4 @@ id: welcome
|
|||||||
name: Welcome
|
name: Welcome
|
||||||
messages:
|
messages:
|
||||||
- Welcome the user.
|
- Welcome the user.
|
||||||
|
resource: welcome.txt
|
||||||
|
|||||||
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: []
|
||||||
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 }},
|
||||||
|
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "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 }},
|
||||||
|
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
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")},
|
"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")},
|
||||||
@@ -68,19 +68,17 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sourceOrder := make(map[string]int)
|
|
||||||
sourceFiles := make(map[string]string)
|
sourceFiles := make(map[string]string)
|
||||||
for index, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
sourceOrder[source.ID] = index
|
|
||||||
sourceFiles[source.ID] = source.Path
|
sourceFiles[source.ID] = source.Path
|
||||||
}
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
path configkit.Path
|
path configkit.Path
|
||||||
file, text string
|
file, text string
|
||||||
}{
|
}{
|
||||||
{configkit.Keys("records").Index(1).Key("text"), "helper-one.yml", "first helper\n"},
|
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||||
{configkit.Keys("records").Index(2).Key("text"), "helper-two.yml", "second helper\n"},
|
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||||
{configkit.Keys("records").Index(1).Key("children").Index(0).Key("text"), "child-helper.yml", "nested 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)
|
explanation, ok := result.Explain(test.path)
|
||||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||||
@@ -95,20 +93,6 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
|||||||
t.Fatal("metadata and explanation disagree")
|
t.Fatal("metadata and explanation disagree")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
explanation, ok := result.Explain(configkit.Keys())
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("root explanation missing")
|
|
||||||
}
|
|
||||||
previous := -1
|
|
||||||
for _, change := range explanation.History {
|
|
||||||
for _, location := range change.Origin.Locations {
|
|
||||||
current := sourceOrder[location.SourceID]
|
|
||||||
if current < previous {
|
|
||||||
t.Fatalf("source %s replayed out of traversal order", location.SourceID)
|
|
||||||
}
|
|
||||||
previous = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||||
@@ -158,9 +142,9 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
name, file, path string
|
name, file, path string
|
||||||
grouped, itemMain bool
|
grouped, itemMain bool
|
||||||
}{
|
}{
|
||||||
{"preceding authored and imported items", "main.yml", "/records/2/id", false, false},
|
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||||
{"nested item", "nested.yml", "/records/1/children/1/id", false, false},
|
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||||
{"preceding grouped input", "group.yml", "/records/2/id", true, false},
|
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||||
} {
|
} {
|
||||||
@@ -193,8 +177,16 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
for _, source := range loadError.Sources() {
|
for _, source := range loadError.Sources() {
|
||||||
files[source.ID] = source.Path
|
files[source.ID] = source.Path
|
||||||
}
|
}
|
||||||
if diagnostic.Primary == nil || files[diagnostic.Primary.SourceID] != "bad.yml" || len(diagnostic.Related) != 1 || files[diagnostic.Related[0].SourceID] != "helper.yml" {
|
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||||
t.Fatal("conflict lost physical source locations")
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -326,7 +318,7 @@ items:
|
|||||||
t.Fatalf("load item glob: %v", err)
|
t.Fatalf("load item glob: %v", err)
|
||||||
}
|
}
|
||||||
items := result.Config["items"].([]any)
|
items := result.Config["items"].([]any)
|
||||||
wantIDs := []string{"authored", "imported-a", "imported-b"}
|
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||||
for index, item := range items {
|
for index, item := range items {
|
||||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||||
@@ -344,10 +336,10 @@ items:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata := result.Metadata()
|
metadata := result.Metadata()
|
||||||
for index := 1; index < len(wantIDs); index++ {
|
for index := 0; index < 2; index++ {
|
||||||
path := configkit.Keys("items").Index(index).Key("id")
|
path := configkit.Keys("items").Index(index).Key("id")
|
||||||
info, ok := metadata.Lookup(path)
|
info, ok := metadata.Lookup(path)
|
||||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-1] {
|
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)
|
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ import (
|
|||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
|
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
for _, grouped := range []bool{false, true} {
|
for _, grouped := range []bool{false, true} {
|
||||||
for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} {
|
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) {
|
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
||||||
@@ -46,6 +50,16 @@ func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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()
|
sources := result.Sources()
|
||||||
check := sources[len(sources)-1].VersionCheck
|
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 {
|
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||||
@@ -626,7 +640,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||||
}}
|
}}
|
||||||
path := filepath.Join(t.TempDir(), "main.yml")
|
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 {
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|||||||
type composedDocument struct {
|
type composedDocument struct {
|
||||||
root *document.Node
|
root *document.Node
|
||||||
acquisition []acquisitionScope
|
acquisition []acquisitionScope
|
||||||
|
importGraph importInclusionGraph
|
||||||
claims []compositionClaim
|
claims []compositionClaim
|
||||||
sequenceRanges []sequenceRange
|
sequenceRanges []sequenceRange
|
||||||
origins map[string]Origin
|
origins map[string]Origin
|
||||||
@@ -51,6 +52,7 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
|
|||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||||
|
importGraph: base.importGraph.clone(),
|
||||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||||
applications: cloneHistoryApplications(base.applications),
|
applications: cloneHistoryApplications(base.applications),
|
||||||
|
|||||||
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,11 +345,11 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "optional missing path",
|
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",
|
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) {
|
setup: func(t *testing.T, directory string) {
|
||||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -451,7 +451,6 @@ func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: optional.yml
|
- path: optional.yml
|
||||||
optional: true
|
|
||||||
`)
|
`)
|
||||||
loaded, err := LoadLayered(
|
loaded, err := LoadLayered(
|
||||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
||||||
|
|||||||
@@ -9,27 +9,16 @@ import (
|
|||||||
|
|
||||||
const directiveKey = "configkit"
|
const directiveKey = "configkit"
|
||||||
|
|
||||||
type importDeclaration struct {
|
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||||
path string
|
|
||||||
glob string
|
|
||||||
destination Path
|
|
||||||
placement placementMode
|
|
||||||
placementPath Path
|
|
||||||
placementLocation *Location
|
|
||||||
optional bool
|
|
||||||
location Location
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
|
||||||
body := document.Clone(root)
|
body := document.Clone(root)
|
||||||
var declarations []importDeclaration
|
var declarations []importDeclarationSpec
|
||||||
directiveIndex := -1
|
directiveIndex := -1
|
||||||
for index, entry := range body.Mapping {
|
for index, entry := range body.Mapping {
|
||||||
if entry.Key != directiveKey {
|
if entry.Key != directiveKey {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
directiveIndex = index
|
directiveIndex = index
|
||||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
parsed, diagnostics := parseImportSpecificationDirective(entry.Value, Keys(directiveKey))
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, nil, diagnostics
|
return nil, nil, diagnostics
|
||||||
}
|
}
|
||||||
@@ -45,101 +34,6 @@ func splitImportDirectives(root *document.Node) (*document.Node, []importDeclara
|
|||||||
return body, declarations, nil
|
return body, declarations, 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)}
|
|
||||||
}
|
|
||||||
var declarations []importDeclaration
|
|
||||||
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), placementPath: declarationPath.clone()}
|
|
||||||
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)}
|
|
||||||
}
|
|
||||||
seenTarget = true
|
|
||||||
declaration.location = publicLocation(entry.Value.Location)
|
|
||||||
if entry.Key == "path" {
|
|
||||||
declaration.path = entry.Value.Text
|
|
||||||
} else {
|
|
||||||
declaration.glob = entry.Value.Text
|
|
||||||
}
|
|
||||||
case "optional":
|
|
||||||
if entry.Value.Kind != document.Boolean {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
|
||||||
}
|
|
||||||
declaration.optional = entry.Value.Text == "true"
|
|
||||||
case "destination":
|
|
||||||
if entry.Value.Kind != document.Sequence {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", entry.Value)}
|
|
||||||
}
|
|
||||||
relative := Path{segments: make([]pathSegment, 0, len(entry.Value.Sequence))}
|
|
||||||
for index, item := range entry.Value.Sequence {
|
|
||||||
memberPath := fieldPath.Index(index)
|
|
||||||
if item.Kind != document.String {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)}
|
|
||||||
}
|
|
||||||
member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}}
|
|
||||||
if _, err := normalizeDestination(member); err != nil {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)}
|
|
||||||
}
|
|
||||||
relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text})
|
|
||||||
}
|
|
||||||
normalized, err := normalizeDestination(relative)
|
|
||||||
if err != nil {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", entry.Value)}
|
|
||||||
}
|
|
||||||
declaration.destination = normalized
|
|
||||||
case "placement":
|
|
||||||
declaration.placementPath = fieldPath.clone()
|
|
||||||
placementLocation := publicLocation(entry.Value.Location)
|
|
||||||
declaration.placementLocation = &placementLocation
|
|
||||||
if entry.Value.Kind != document.String {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
|
||||||
}
|
|
||||||
switch entry.Value.Text {
|
|
||||||
case "mapping":
|
|
||||||
declaration.placement = placementModeMapping
|
|
||||||
case "sequence-item":
|
|
||||||
declaration.placement = placementModeSequenceItem
|
|
||||||
default:
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !seenTarget {
|
|
||||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
|
||||||
}
|
|
||||||
return declaration, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,130 +5,223 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const globReadBatch = 128
|
const globReadBatch = 128
|
||||||
|
|
||||||
type globCandidate struct {
|
// selectGlobImportCandidates expands path components while retaining the
|
||||||
absolute string
|
// lexical spelling used for filtering and later source admission.
|
||||||
relative string
|
func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting) (importSelection, []Diagnostic) {
|
||||||
}
|
site := spec.fieldSites["glob"]
|
||||||
|
segments := spec.globSegments
|
||||||
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
|
if len(segments) == 0 {
|
||||||
location := declaration.location
|
var valid bool
|
||||||
segments, valid := validGlobPattern(declaration.glob)
|
segments, valid = validGlobPattern(spec.glob)
|
||||||
if !valid {
|
if !valid {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
|
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if accounting == nil {
|
||||||
candidates := []globCandidate{{absolute: basePath}}
|
accounting = &importAccounting{}
|
||||||
|
}
|
||||||
|
rootPath := normalizedImportCandidatePath(access, basePath)
|
||||||
|
candidates := []selectedImportCandidate{{path: rootPath, accessPath: basePath, site: site}}
|
||||||
for index, segment := range segments {
|
for index, segment := range segments {
|
||||||
last := index == len(segments)-1
|
last := index == len(segments)-1
|
||||||
literal, patterned := globLiteralSegment(segment)
|
literal, patterned := globLiteralSegment(segment)
|
||||||
var next []globCandidate
|
// nextByPath is the set union of candidates reached from each matching
|
||||||
for _, candidate := range candidates {
|
// parent at this segment; final exclusions below are set difference.
|
||||||
var names []string
|
nextByPath := make(map[string]selectedImportCandidate)
|
||||||
if patterned {
|
for _, parent := range candidates {
|
||||||
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, diagnostics
|
return importSelection{}, diagnostics
|
||||||
}
|
}
|
||||||
names = matched
|
if !missing {
|
||||||
} else {
|
nextByPath[candidate.path] = candidate
|
||||||
names = []string{literal}
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
for _, name := range names {
|
|
||||||
absolute := s.access.join(candidate.absolute, name)
|
entries, missing, diagnostics := readGlobEntries(access, parent.accessPath, site.location, limits, accounting)
|
||||||
relative := path.Join(candidate.relative, name)
|
if len(diagnostics) != 0 {
|
||||||
if !s.access.contains(absolute) {
|
return importSelection{}, diagnostics
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
}
|
||||||
}
|
if missing {
|
||||||
canonical, err := s.access.canonical(absolute)
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
matched, err := path.Match(segment, entry.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
|
||||||
}
|
}
|
||||||
if !s.access.contains(canonical) {
|
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||||
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)}
|
|
||||||
}
|
|
||||||
continue
|
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 {
|
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() {
|
if target.symlink && !policy.follow {
|
||||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
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 {
|
if len(candidates) == 0 {
|
||||||
return nil, nil
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := make([]string, len(candidates))
|
selected := make([]selectedImportCandidate, 0, len(candidates))
|
||||||
for index, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
matches[index] = candidate.relative
|
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)
|
if policy.required && len(selected) == 0 {
|
||||||
return matches, nil
|
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) {
|
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
||||||
canonical, err := s.access.canonical(directory)
|
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 err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
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) {
|
canonical, err := access.canonical(accessPath)
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
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 {
|
if !access.contains(canonical) {
|
||||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal resolves outside the configuration root")}
|
||||||
}
|
}
|
||||||
var matches []string
|
if !last {
|
||||||
var problem *Diagnostic
|
if !target.dir {
|
||||||
err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
|
return selectedImportCandidate{}, true, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
matched, matchErr := path.Match(pattern, entry.Name())
|
}
|
||||||
if matchErr != nil {
|
return selectedImportCandidate{
|
||||||
value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
|
path: normalizedImportCandidatePath(access, candidatePath), accessPath: canonical,
|
||||||
problem = &value
|
label: relativeImportLabel(access, basePath, candidatePath), site: site,
|
||||||
return false
|
}, 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 {
|
return nil, false, []Diagnostic{globCandidateDiagnostic(err, location, "glob directory could not be inspected")}
|
||||||
matches = append(matches, entry.Name())
|
}
|
||||||
|
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 {
|
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||||
return nil, []Diagnostic{*problem}
|
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||||
}
|
}
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
if err != 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)
|
sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() })
|
||||||
return matches, nil
|
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) {
|
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
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -106,7 +107,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
|||||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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 {
|
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
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})
|
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
if err != nil || len(result.Sources()) != 1 {
|
if err != nil || len(result.Sources()) != 1 {
|
||||||
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
|
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 {
|
if err := os.MkdirAll(filepath.Join(directory, "conf/matched.yml"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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)
|
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
if err != nil || result.Config.Items["linked"] != "linked" {
|
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)
|
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 {
|
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
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)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -198,7 +203,7 @@ func TestOptionalGlobDoesNotSuppressDirectoryPermissionFailure(t *testing.T) {
|
|||||||
_ = opened.Close()
|
_ = opened.Close()
|
||||||
t.Skip("filesystem does not enforce the unreadable-directory fixture")
|
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)
|
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"} {
|
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
|
||||||
writeImportFile(t, directory, "many/"+name, "ignored\n")
|
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)
|
main := parseImportMain(t, mainPath)
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
limits.entries = 2
|
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()
|
t.Helper()
|
||||||
optionalLine := ""
|
requiredLine := ""
|
||||||
if optional {
|
if required {
|
||||||
optionalLine = "\n optional: true"
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
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, "./") }
|
||||||
463
internal/engine/import_spec.go
Normal file
463
internal/engine/import_spec.go
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
if entry.Value.Kind != document.Sequence {
|
||||||
|
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for index, declarationNode := range entry.Value.Sequence {
|
||||||
|
spec, issues := parseImportDeclarationSpec(declarationNode, fieldPath.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
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ func defaultImportLimits() importLimits {
|
|||||||
type importRoute struct {
|
type importRoute struct {
|
||||||
canonical string
|
canonical string
|
||||||
locations []Location
|
locations []Location
|
||||||
|
key importNodeKey
|
||||||
}
|
}
|
||||||
|
|
||||||
type importAccounting struct {
|
type importAccounting struct {
|
||||||
@@ -50,6 +51,7 @@ type importTraversal struct {
|
|||||||
limits importLimits
|
limits importLimits
|
||||||
rule *VersionRule
|
rule *VersionRule
|
||||||
versionState *sourceVersionState
|
versionState *sourceVersionState
|
||||||
|
defaults importDefaults
|
||||||
|
|
||||||
scope acquisitionScope
|
scope acquisitionScope
|
||||||
itemScope bool
|
itemScope bool
|
||||||
@@ -59,6 +61,7 @@ type importTraversal struct {
|
|||||||
active map[resourceIdentity]int
|
active map[resourceIdentity]int
|
||||||
visited map[resourceIdentity]importRoute
|
visited map[resourceIdentity]importRoute
|
||||||
stack []importRoute
|
stack []importRoute
|
||||||
|
importGraph importInclusionGraph
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
@@ -78,12 +81,16 @@ func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemC
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedDocumentsTrackedWithProcessing(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedDocumentsTrackedWithProcessing(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedDocumentsTrackedWithProcessingDeferred(source, context, main, destination, rule, limits, accounting, visited, hostToken, versionState, processing, policy, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedDocumentsTrackedWithProcessingDeferred(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy, deferAssembly bool) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
if nativeContext(source, context) {
|
if nativeContext(source, context) {
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
|
return loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy, deferAssembly)
|
||||||
}
|
}
|
||||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -98,7 +105,7 @@ func loadImportedDocumentsTrackedWithProcessing(source Source, context *Filesyst
|
|||||||
if hostToken != nil {
|
if hostToken != nil {
|
||||||
access.token = hostToken
|
access.token = hostToken
|
||||||
}
|
}
|
||||||
return loadImportedWithAccessTrackedAndProcessingPolicy(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy)
|
return loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy, deferAssembly)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
@@ -122,6 +129,10 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
|
func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
|
return loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing, policy, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImportedWithAccessTrackedAndProcessingPolicyDeferred(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult, policy sequencePolicy, deferAssembly bool) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||||
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
||||||
_ = access.close()
|
_ = access.close()
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
|
||||||
@@ -139,22 +150,29 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
if mainPath != "" {
|
if mainPath != "" {
|
||||||
mainInfo.Path = mainPath
|
mainInfo.Path = mainPath
|
||||||
}
|
}
|
||||||
mainKey := access.identity(mainPath)
|
mainResource := access.identity(mainPath)
|
||||||
|
mainNodeKey := importKeyFor(access, mainPath, mainPath == "")
|
||||||
if mainPath == "" {
|
if mainPath == "" {
|
||||||
mainKey = access.syntheticIdentity()
|
mainResource = access.syntheticIdentity()
|
||||||
}
|
}
|
||||||
if previous, repeated := visited[mainKey]; repeated {
|
if previous, repeated := visited[mainResource]; repeated {
|
||||||
_ = access.close()
|
_ = access.close()
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||||
}
|
}
|
||||||
accounting.sources++
|
accounting.sources++
|
||||||
accounting.bytes += main.bytes
|
accounting.bytes += main.bytes
|
||||||
accounting.nodes += countInputNodes(main.document)
|
accounting.nodes += countInputNodes(main.document)
|
||||||
|
defaults := importDefaults{}
|
||||||
|
if versionState != nil {
|
||||||
|
defaults = snapshotImportDefaults(versionState.importDefaults)
|
||||||
|
}
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
|
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
|
||||||
scope: acquisitionScope{destination: destination.clone()},
|
defaults: defaults,
|
||||||
sources: []SourceInfo{mainInfo},
|
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
||||||
active: make(map[resourceIdentity]int), visited: visited,
|
sources: []SourceInfo{mainInfo},
|
||||||
|
active: make(map[resourceIdentity]int), visited: visited,
|
||||||
|
importGraph: newImportInclusionGraph(),
|
||||||
}
|
}
|
||||||
if mainIsItem {
|
if mainIsItem {
|
||||||
state.itemScope = true
|
state.itemScope = true
|
||||||
@@ -176,11 +194,13 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
mainIdentity = "<selected-source>"
|
mainIdentity = "<selected-source>"
|
||||||
}
|
}
|
||||||
mainRoute := importRoute{canonical: mainIdentity}
|
mainRoute := importRoute{canonical: mainIdentity}
|
||||||
state.active[mainKey] = 0
|
state.importGraph.addNode(importGraphNode{key: mainNodeKey, canonical: mainIdentity, sourceID: mainInfo.ID, role: importRoleMain})
|
||||||
state.visited[mainKey] = mainRoute
|
mainRoute.key = mainNodeKey
|
||||||
|
state.active[mainResource] = 0
|
||||||
|
state.visited[mainResource] = mainRoute
|
||||||
state.stack = append(state.stack, mainRoute)
|
state.stack = append(state.stack, mainRoute)
|
||||||
var body *document.Node
|
var body *document.Node
|
||||||
var declarations []importDeclaration
|
var declarations []importDeclarationSpec
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
if processing != nil {
|
if processing != nil {
|
||||||
body = processing.payload
|
body = processing.payload
|
||||||
@@ -191,7 +211,8 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
if mainIsItem {
|
if mainIsItem {
|
||||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||||
source: cloneSourceInfo(mainInfo), payload: body, document: body, processing: processing,
|
source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain,
|
||||||
|
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
||||||
})
|
})
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{})
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{})
|
||||||
} else {
|
} else {
|
||||||
@@ -205,12 +226,19 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
accounting.nodes += placementNodes
|
accounting.nodes += placementNodes
|
||||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||||
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
||||||
|
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||||
destination: destination.clone(), processing: processing,
|
destination: destination.clone(), processing: processing,
|
||||||
})
|
})
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
state.scheduleAcquisitionScopes(&state.scope)
|
||||||
|
if !deferAssembly {
|
||||||
|
diagnostics = state.composePendingItems(&state.scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
closeErr := access.close()
|
closeErr := access.close()
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -222,6 +250,27 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
}
|
}
|
||||||
|
if deferAssembly {
|
||||||
|
acquisition := state.scope
|
||||||
|
if mainIsItem {
|
||||||
|
localScope := state.scope
|
||||||
|
acquisition = acquisitionScope{
|
||||||
|
owner: mainNodeKey, destination: destination.clone(),
|
||||||
|
contributions: []acquiredContribution{{
|
||||||
|
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||||
|
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
|
||||||
|
processing: processing, pendingItem: &localScope,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
|
||||||
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
|
}
|
||||||
|
return &composedDocument{
|
||||||
|
acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
|
||||||
|
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
|
||||||
|
}, cloneSourceInfos(state.sources), nil
|
||||||
|
}
|
||||||
if mainIsItem {
|
if mainIsItem {
|
||||||
localScope := state.scope
|
localScope := state.scope
|
||||||
documents := make([]*document.Node, 0, len(localScope.contributions))
|
documents := make([]*document.Node, 0, len(localScope.contributions))
|
||||||
@@ -245,7 +294,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
protectionRule = &scoped
|
protectionRule = &scoped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if diagnostic := importedVersionClaim(documents, completed, protectionRule, 0); diagnostic != nil {
|
if diagnostic := importedVersionClaim(documents, localScope, completed, protectionRule); diagnostic != nil {
|
||||||
if rule != nil {
|
if rule != nil {
|
||||||
diagnostic.Path = rule.Path.clone()
|
diagnostic.Path = rule.Path.clone()
|
||||||
}
|
}
|
||||||
@@ -267,13 +316,16 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
if composed != nil {
|
if composed != nil {
|
||||||
synthetic := acquisitionScope{contributions: []acquiredContribution{{
|
synthetic := acquisitionScope{contributions: []acquiredContribution{{
|
||||||
source: cloneSourceInfo(mainInfo), payload: completed.root, document: placed,
|
source: cloneSourceInfo(mainInfo), payload: completed.root, document: placed,
|
||||||
|
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||||
destination: destination.clone(), processing: processing, nested: completed,
|
destination: destination.clone(), processing: processing, nested: completed,
|
||||||
}}}
|
}}}
|
||||||
|
synthetic.owner = mainNodeKey
|
||||||
projectNestedEvidence(composed, synthetic)
|
projectNestedEvidence(composed, synthetic)
|
||||||
projectAcquiredDestinations(composed, synthetic, state.sources)
|
projectAcquiredDestinations(composed, synthetic, state.sources)
|
||||||
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
|
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
|
||||||
composed.inputBytes = accounting.bytes
|
composed.inputBytes = accounting.bytes
|
||||||
composed.inputNodes = accounting.nodes
|
composed.inputNodes = accounting.nodes
|
||||||
|
composed.importGraph = state.importGraph.clone()
|
||||||
}
|
}
|
||||||
return composed, cloneSourceInfos(state.sources), failures
|
return composed, cloneSourceInfos(state.sources), failures
|
||||||
}
|
}
|
||||||
@@ -283,7 +335,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
}
|
}
|
||||||
composed, diagnostics := composeDocumentsWithPolicy(policy, documents...)
|
composed, diagnostics := composeDocumentsWithPolicy(policy, documents...)
|
||||||
if composed != nil {
|
if composed != nil {
|
||||||
if diagnostic := importedVersionClaim(documents, composed, rule, 0); diagnostic != nil {
|
if diagnostic := importedVersionClaim(documents, state.scope, composed, rule); diagnostic != nil {
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,10 +345,34 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)}
|
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)}
|
||||||
composed.inputBytes = accounting.bytes
|
composed.inputBytes = accounting.bytes
|
||||||
composed.inputNodes = accounting.nodes
|
composed.inputNodes = accounting.nodes
|
||||||
|
composed.importGraph = state.importGraph.clone()
|
||||||
}
|
}
|
||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic {
|
||||||
|
if scope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for index := range scope.contributions {
|
||||||
|
contribution := &scope.contributions[index]
|
||||||
|
if contribution.pendingItem == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem)
|
||||||
|
if accounting.nodes+placementNodes > limits.nodes {
|
||||||
|
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)
|
||||||
|
return &diagnostic
|
||||||
|
}
|
||||||
|
accounting.nodes += placementNodes
|
||||||
|
contribution.placementCharged = true
|
||||||
|
if diagnostic := reservePendingItemPlacements(contribution.pendingItem, limits, accounting); diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
if s.accounting.sources > s.limits.sources {
|
if s.accounting.sources > s.limits.sources {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
@@ -313,62 +389,77 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int, parentDestination Path) []Diagnostic {
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path) []Diagnostic {
|
||||||
for _, declaration := range declarations {
|
policies := make([]resolvedImportPolicy, len(declarations))
|
||||||
childDestination := appendDestination(parentDestination, declaration.destination)
|
for index, spec := range declarations {
|
||||||
if declaration.glob != "" {
|
policy, diagnostics := resolveImportPolicy(spec, s.defaults)
|
||||||
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
if len(diagnostics) != 0 {
|
||||||
if len(diagnostics) != 0 {
|
return diagnostics
|
||||||
|
}
|
||||||
|
if policy.placement == placementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
||||||
|
site := spec.fieldSites["placement"]
|
||||||
|
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
||||||
|
}
|
||||||
|
policies[index] = policy
|
||||||
|
}
|
||||||
|
for index, spec := range declarations {
|
||||||
|
policy := policies[index]
|
||||||
|
var selection importSelection
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
switch spec.selector {
|
||||||
|
case importSelectorPath:
|
||||||
|
selection, diagnostics = selectNamedImportCandidates(s.access, basePath, spec, policy)
|
||||||
|
case importSelectorDirectory:
|
||||||
|
selection, diagnostics = selectDirectoryImportCandidates(s.access, basePath, spec, policy, s.limits, s.accounting)
|
||||||
|
case importSelectorGlob:
|
||||||
|
selection, diagnostics = selectGlobImportCandidates(s.access, basePath, spec, policy, s.limits, s.accounting)
|
||||||
|
default:
|
||||||
|
location := spec.location
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import selector is invalid", &location)}
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
childDestination := appendDestination(parentDestination, policy.destination)
|
||||||
|
// Count admitted records rather than lexical candidates: optional missing
|
||||||
|
// paths are omitted during admission, and a partially present list is not empty.
|
||||||
|
sourcesBefore := len(s.sources)
|
||||||
|
for _, candidate := range selection.candidates {
|
||||||
|
if diagnostics := s.visitPath(candidate, spec, policy, childDestination, policy.destination, depth+1); len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
if len(matches) == 0 {
|
|
||||||
if declaration.optional {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
location := declaration.location
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportMissing, "required import glob matched no files", &location)}
|
|
||||||
}
|
|
||||||
for _, match := range matches {
|
|
||||||
matched := declaration
|
|
||||||
matched.path = match
|
|
||||||
matched.glob = ""
|
|
||||||
if diagnostics := s.visitPath(basePath, matched, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 {
|
|
||||||
return diagnostics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if diagnostics := s.visitPath(basePath, declaration, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 {
|
if !policy.required && len(s.sources) == sourcesBefore {
|
||||||
return diagnostics
|
s.recordEmptyImportSelection(spec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination, relativeDestination Path, depth int) []Diagnostic {
|
func (s *importTraversal) recordEmptyImportSelection(spec importDeclarationSpec) {
|
||||||
location := declaration.location
|
for index := range s.sources {
|
||||||
if declaration.placement == placementModeSequenceItem && len(destination.segments) == 0 {
|
if s.sources[index].ID != spec.location.SourceID {
|
||||||
return []Diagnostic{{
|
continue
|
||||||
Code: CodeImportInvalid, Severity: SeverityError,
|
}
|
||||||
Path: declaration.placementPath.clone(),
|
s.sources[index].importSelections = append(s.sources[index].importSelections, importSelectionEvidence{
|
||||||
Message: "sequence-item placement requires a nonempty destination",
|
path: spec.path.clone(), location: spec.location,
|
||||||
Primary: cloneLocation(declaration.placementLocation),
|
selector: spec.selector, outcome: importSelectionNone,
|
||||||
}}
|
})
|
||||||
}
|
return
|
||||||
if depth > s.limits.depth {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
|
||||||
}
|
|
||||||
if !validImportPath(declaration.path) {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
candidate := s.access.join(basePath, declaration.path)
|
func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec importDeclarationSpec, policy resolvedImportPolicy, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||||
if !s.access.contains(candidate) {
|
location := candidate.site.location
|
||||||
|
if location.SourceID == "" && location.Line == 0 && location.Column == 0 {
|
||||||
|
location = spec.location
|
||||||
|
}
|
||||||
|
if !s.access.contains(candidate.accessPath) {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
canonical, err := s.access.canonical(candidate)
|
canonical, err := s.access.canonical(candidate.accessPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
if !policy.required && errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
code := CodeImportInvalid
|
code := CodeImportInvalid
|
||||||
@@ -383,14 +474,24 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
if !s.access.contains(canonical) {
|
if !s.access.contains(canonical) {
|
||||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
||||||
}
|
}
|
||||||
|
if depth > s.limits.depth {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||||
|
}
|
||||||
|
|
||||||
currentRoute := s.currentRoute(location)
|
currentRoute := s.currentRoute(location)
|
||||||
identity := s.access.identity(canonical)
|
identity := s.access.identity(canonical)
|
||||||
|
nodeKey := importKeyFor(s.access, canonical, false)
|
||||||
|
importerKey := s.currentImportNode()
|
||||||
if activeIndex, ok := s.active[identity]; ok {
|
if activeIndex, ok := s.active[identity]; ok {
|
||||||
|
closingEdge := s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||||
|
if diagnostic, ok := s.importGraph.scheduleCycle(closingEdge, location); ok {
|
||||||
|
return []Diagnostic{diagnostic}
|
||||||
|
}
|
||||||
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
||||||
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||||
}
|
}
|
||||||
if previous, ok := s.visited[identity]; ok {
|
if previous, ok := s.visited[identity]; ok {
|
||||||
|
s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||||
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||||
}
|
}
|
||||||
if s.accounting.sources+1 > s.limits.sources {
|
if s.accounting.sources+1 > s.limits.sources {
|
||||||
@@ -402,13 +503,16 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
}
|
}
|
||||||
sourceID := "source-" + strconv.Itoa(s.accounting.sources+1)
|
sourceID := "source-" + strconv.Itoa(s.accounting.sources+1)
|
||||||
kind := SourceKindFile
|
kind := SourceKindFile
|
||||||
label := declaration.path
|
label := candidate.label
|
||||||
|
if label == "" {
|
||||||
|
label = candidate.path
|
||||||
|
}
|
||||||
if s.access.native != nil {
|
if s.access.native != nil {
|
||||||
kind = SourceKindFilesystem
|
kind = SourceKindFilesystem
|
||||||
label = s.access.label + "/" + canonical
|
label = s.access.label + "/" + canonical
|
||||||
}
|
}
|
||||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||||
if declaration.placement == placementModeSequenceItem {
|
if policy.placement == placementModeSequenceItem {
|
||||||
info.placementMode = placementModeSequenceItem
|
info.placementMode = placementModeSequenceItem
|
||||||
}
|
}
|
||||||
// A source's own item placement still has an exact sequence-container
|
// A source's own item placement still has an exact sequence-container
|
||||||
@@ -419,7 +523,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
info.namespace = s.access.token
|
info.namespace = s.access.token
|
||||||
}
|
}
|
||||||
s.sources = append(s.sources, info)
|
s.sources = append(s.sources, info)
|
||||||
data, skipped, diagnostic := s.readImport(canonical, sourceID, declaration.optional)
|
data, skipped, diagnostic := s.readImport(canonical, sourceID, policy.required)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return []Diagnostic{*diagnostic}
|
return []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -442,7 +546,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
s.accounting.nodes += nodeCount
|
s.accounting.nodes += nodeCount
|
||||||
|
|
||||||
var body *document.Node
|
var body *document.Node
|
||||||
var declarations []importDeclaration
|
var declarations []importDeclarationSpec
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
var processing *sourceVersionProcessingResult
|
var processing *sourceVersionProcessingResult
|
||||||
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
|
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
|
||||||
@@ -450,7 +554,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
if s.itemScope {
|
if s.itemScope {
|
||||||
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
|
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
|
||||||
}
|
}
|
||||||
processed, failures := s.versionState.process(root, sourceVersionContribution, versionDestination, sourceID, declaration.placement)
|
processed, failures := s.versionState.process(root, sourceVersionContribution, versionDestination, sourceID, policy.placement)
|
||||||
if len(failures) != 0 {
|
if len(failures) != 0 {
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
@@ -464,15 +568,21 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
}
|
}
|
||||||
placementDestination := destination
|
placementDestination := destination
|
||||||
mode := placementModeMapping
|
mode := placementModeMapping
|
||||||
if declaration.placement == placementModeSequenceItem {
|
if policy.placement == placementModeSequenceItem {
|
||||||
mode = placementModeSequenceItem
|
mode = placementModeSequenceItem
|
||||||
}
|
}
|
||||||
|
role := importRoleMapping
|
||||||
if mode == placementModeSequenceItem {
|
if mode == placementModeSequenceItem {
|
||||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
role = importRoleItem
|
||||||
|
}
|
||||||
|
s.importGraph.addNode(importGraphNode{key: nodeKey, canonical: canonical, sourceID: sourceID, role: role})
|
||||||
|
s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||||
|
if mode == placementModeSequenceItem {
|
||||||
|
route := importRoute{canonical: canonical, locations: currentRoute.locations, key: nodeKey}
|
||||||
s.active[identity] = len(s.stack)
|
s.active[identity] = len(s.stack)
|
||||||
s.visited[identity] = route
|
s.visited[identity] = route
|
||||||
s.stack = append(s.stack, route)
|
s.stack = append(s.stack, route)
|
||||||
diagnostics := s.visitItemSource(canonical, declaration, destination, relativeDestination, depth, info, body, declarations, processing)
|
diagnostics := s.visitItemSource(canonical, nodeKey, destination, relativeDestination, depth, info, body, declarations, processing)
|
||||||
delete(s.active, identity)
|
delete(s.active, identity)
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
return diagnostics
|
return diagnostics
|
||||||
@@ -486,9 +596,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||||
}
|
}
|
||||||
s.accounting.nodes += placementNodes
|
s.accounting.nodes += placementNodes
|
||||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
route := importRoute{canonical: canonical, locations: currentRoute.locations, key: nodeKey}
|
||||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
source: cloneSourceInfo(info), payload: body, document: placed,
|
source: cloneSourceInfo(info), payload: body, document: placed,
|
||||||
|
identity: nodeKey, role: role, scopeOwner: s.scope.owner,
|
||||||
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
||||||
})
|
})
|
||||||
s.active[identity] = len(s.stack)
|
s.active[identity] = len(s.stack)
|
||||||
@@ -500,6 +611,17 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) currentImportNode() importNodeKey {
|
||||||
|
if len(s.stack) != 0 {
|
||||||
|
return s.stack[len(s.stack)-1].key
|
||||||
|
}
|
||||||
|
key := s.scope.owner
|
||||||
|
if _, exists := s.importGraph.index[key]; !exists {
|
||||||
|
s.importGraph.addNode(importGraphNode{key: key, canonical: "<selected-source>", role: importRoleMain})
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
func (s *importTraversal) sourceDestination(destination Path) Path {
|
func (s *importTraversal) sourceDestination(destination Path) Path {
|
||||||
if !s.itemScope {
|
if !s.itemScope {
|
||||||
return destination.clone()
|
return destination.clone()
|
||||||
@@ -509,10 +631,10 @@ func (s *importTraversal) sourceDestination(destination Path) Path {
|
|||||||
return s.itemFallback.clone()
|
return s.itemFallback.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) visitItemSource(canonical string, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic {
|
func (s *importTraversal) visitItemSource(canonical string, identity importNodeKey, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclarationSpec, processing *sourceVersionProcessingResult) []Diagnostic {
|
||||||
parentScope := s.scope
|
parentScope := s.scope
|
||||||
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
||||||
s.scope = acquisitionScope{}
|
s.scope = acquisitionScope{owner: identity}
|
||||||
s.itemScope = true
|
s.itemScope = true
|
||||||
if parentItemScope {
|
if parentItemScope {
|
||||||
s.itemPrefix = appendDestination(parentPrefix, destination)
|
s.itemPrefix = appendDestination(parentPrefix, destination)
|
||||||
@@ -522,47 +644,86 @@ func (s *importTraversal) visitItemSource(canonical string, declaration importDe
|
|||||||
}
|
}
|
||||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
source: cloneSourceInfo(info), payload: body, document: body,
|
source: cloneSourceInfo(info), payload: body, document: body,
|
||||||
|
identity: identity, role: importRoleItem, scopeOwner: identity,
|
||||||
processing: processing,
|
processing: processing,
|
||||||
})
|
})
|
||||||
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
|
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
|
||||||
itemScope := s.scope
|
itemScope := s.scope
|
||||||
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
|
return diagnostics
|
||||||
}
|
|
||||||
documents := make([]*document.Node, 0, len(itemScope.contributions))
|
|
||||||
for _, contribution := range itemScope.contributions {
|
|
||||||
documents = append(documents, contribution.document)
|
|
||||||
}
|
|
||||||
completed, diagnostics := composeDocuments(documents...)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
|
|
||||||
}
|
}
|
||||||
itemScope.destination = Path{}
|
itemScope.destination = Path{}
|
||||||
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(itemScope)}
|
|
||||||
projectNestedEvidence(completed, itemScope)
|
|
||||||
pl := destination
|
|
||||||
_, placed, err := placeDocumentWithMode(completed.root, pl, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes)
|
|
||||||
if err != nil {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &declaration.location)}
|
|
||||||
}
|
|
||||||
placementNodes := destinationWrapperNodeCountForMode(pl, placementModeSequenceItem)
|
|
||||||
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
|
||||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &declaration.location)}
|
|
||||||
}
|
|
||||||
s.accounting.nodes += placementNodes
|
|
||||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
source: cloneSourceInfo(info), payload: completed.root, document: placed,
|
source: cloneSourceInfo(info), payload: body,
|
||||||
destination: pl.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
identity: identity, role: importRoleItem, scopeOwner: parentScope.owner,
|
||||||
nested: completed,
|
destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
||||||
|
pendingItem: &itemScope,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func importedVersionClaim(documents []*document.Node, composed *composedDocument, rule *VersionRule, mainIndex int) *Diagnostic {
|
func (s *importTraversal) scheduleAcquisitionScopes(scope *acquisitionScope) {
|
||||||
|
*scope = scheduleAcquisitionScope(*scope, s.importGraph)
|
||||||
|
for index := range scope.contributions {
|
||||||
|
if pending := scope.contributions[index].pendingItem; pending != nil {
|
||||||
|
s.scheduleAcquisitionScopes(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic {
|
||||||
|
for index := range scope.contributions {
|
||||||
|
contribution := &scope.contributions[index]
|
||||||
|
if contribution.pendingItem == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemScope := contribution.pendingItem
|
||||||
|
if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 {
|
||||||
|
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
|
||||||
|
}
|
||||||
|
documents := make([]*document.Node, 0, len(itemScope.contributions))
|
||||||
|
for _, nested := range itemScope.contributions {
|
||||||
|
documents = append(documents, nested.document)
|
||||||
|
}
|
||||||
|
completed, diagnostics := composeDocuments(documents...)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
|
||||||
|
}
|
||||||
|
// Version ownership is checked by the enclosing composition once this
|
||||||
|
// item has its effective coordinates, not against its local field names.
|
||||||
|
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
|
||||||
|
projectNestedEvidence(completed, *itemScope)
|
||||||
|
_, placed, err := placeDocumentWithMode(completed.root, contribution.destination, placementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
|
if err != nil {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)}
|
||||||
|
}
|
||||||
|
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem)
|
||||||
|
if !contribution.placementCharged {
|
||||||
|
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
||||||
|
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)}
|
||||||
|
}
|
||||||
|
s.accounting.nodes += placementNodes
|
||||||
|
}
|
||||||
|
contribution.payload = completed.root
|
||||||
|
contribution.document = placed
|
||||||
|
contribution.nested = completed
|
||||||
|
contribution.pendingItem = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedVersionClaim(documents []*document.Node, scope acquisitionScope, composed *composedDocument, rule *VersionRule) *Diagnostic {
|
||||||
if composed == nil || rule == nil {
|
if composed == nil || rule == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
mainIndex := -1
|
||||||
|
for index, contribution := range scope.contributions {
|
||||||
|
if contribution.role == importRoleMain {
|
||||||
|
mainIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
ranges := indexSequenceRanges(composed.sequenceRanges)
|
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||||
for index, contribution := range documents {
|
for index, contribution := range documents {
|
||||||
if index == mainIndex {
|
if index == mainIndex {
|
||||||
@@ -599,10 +760,12 @@ func isCompositionDiagnostic(diagnostic Diagnostic) bool {
|
|||||||
// On a composition failure only the earlier contributions are needed to locate
|
// On a composition failure only the earlier contributions are needed to locate
|
||||||
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
|
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
|
||||||
// and access diagnostics keep their original source-local coordinates.
|
// and access diagnostics keep their original source-local coordinates.
|
||||||
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path) []Diagnostic {
|
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path, beforeContribution int) []Diagnostic {
|
||||||
for _, diagnostic := range diagnostics {
|
for _, diagnostic := range diagnostics {
|
||||||
if isCompositionDiagnostic(diagnostic) {
|
if isCompositionDiagnostic(diagnostic) {
|
||||||
index := priorSequenceLength([]acquisitionScope{parent}, destination)
|
prefix := parent
|
||||||
|
prefix.contributions = parent.contributions[:beforeContribution]
|
||||||
|
index := priorSequenceLength([]acquisitionScope{prefix}, destination)
|
||||||
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
|
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -774,7 +937,7 @@ func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, des
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
|
func (s *importTraversal) readImport(name, sourceID string, required bool) ([]byte, bool, *Diagnostic) {
|
||||||
remaining := s.limits.bytes - s.accounting.bytes
|
remaining := s.limits.bytes - s.accounting.bytes
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
@@ -782,7 +945,7 @@ func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]by
|
|||||||
}
|
}
|
||||||
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
|
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
|
||||||
if failure != nil {
|
if failure != nil {
|
||||||
if optional && failure.missingTarget {
|
if !required && failure.missingTarget {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
|
|||||||
@@ -91,6 +91,80 @@ settings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadOrdinaryImportsUseDependencyReadyCompositionOrder(t *testing.T) {
|
||||||
|
type task struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Tasks []task `yaml:"tasks"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: z-parent.yml
|
||||||
|
- path: a-independent.yml
|
||||||
|
tasks: [{id: main}]
|
||||||
|
`)
|
||||||
|
zParentPath := writeImportFile(t, directory, "z-parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: b-dependency.yml
|
||||||
|
tasks: [{id: z-parent}]
|
||||||
|
`)
|
||||||
|
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
|
||||||
|
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
|
||||||
|
|
||||||
|
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
|
||||||
|
Imports: true,
|
||||||
|
sequencePolicy: sequencePolicy{identityFields: map[string]string{"/tasks": "id"}},
|
||||||
|
})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("assemble imports: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
tasks, ok := documentAtPath(input.document, Keys("tasks"))
|
||||||
|
if !ok || tasks.Kind != document.Sequence {
|
||||||
|
t.Fatalf("tasks document = %#v, %v", tasks, ok)
|
||||||
|
}
|
||||||
|
wantOrder := []string{"a-independent", "b-dependency", "z-parent", "main"}
|
||||||
|
if len(tasks.Sequence) != len(wantOrder) {
|
||||||
|
t.Fatalf("tasks = %#v", tasks.Sequence)
|
||||||
|
}
|
||||||
|
for index, want := range wantOrder {
|
||||||
|
id, found := documentAtPath(tasks, Path{}.Index(index).Key("id"))
|
||||||
|
if !found || id.Text != want {
|
||||||
|
t.Errorf("task %d ID = %#v, want %q", index, id, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantAcquisition := []string{
|
||||||
|
canonicalTestPath(t, mainPath), canonicalTestPath(t, zParentPath),
|
||||||
|
canonicalTestPath(t, bDependencyPath), canonicalTestPath(t, aIndependentPath),
|
||||||
|
}
|
||||||
|
for index, want := range wantAcquisition {
|
||||||
|
if got := input.sources[index].Path; got != want {
|
||||||
|
t.Errorf("acquired source %d = %q, want %q", index, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcquiredContextsUseSelectedFileWhenSourceRecordsAreReordered(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "settings: {root: main}\n")
|
||||||
|
importedPath := writeImportFile(t, directory, "nested/imported.yml", "settings: {nested: imported}\n")
|
||||||
|
sources := []SourceInfo{
|
||||||
|
{ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile},
|
||||||
|
{ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile},
|
||||||
|
}
|
||||||
|
contexts, diagnostic := contextsForAcquiredSources(File(mainPath), nil, nil, sources)
|
||||||
|
if diagnostic != nil {
|
||||||
|
t.Fatalf("source contexts: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
if got := contexts["source-1"].BaseDir; got != "." {
|
||||||
|
t.Errorf("main source base = %q, want root-relative current directory", got)
|
||||||
|
}
|
||||||
|
if got := contexts["source-2"].BaseDir; got != "nested" {
|
||||||
|
t.Errorf("imported source base = %q, want nested", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
|
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")
|
||||||
@@ -153,10 +227,11 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||||
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||||
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
||||||
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
{name: "missing target", body: "configkit: {imports: [{required: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
{name: "removed optional field", body: "configkit: {imports: [{path: a.yml, optional: true}]}\n", code: CodeImportInvalid},
|
||||||
|
{name: "required kind", body: "configkit: {imports: [{path: a.yml, required: 'yes'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "placement kind", body: "configkit: {imports: [{path: a.yml, placement: true}]}\n", code: CodeImportInvalid},
|
{name: "placement kind", body: "configkit: {imports: [{path: a.yml, placement: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "placement value", body: "configkit: {imports: [{path: a.yml, placement: item}]}\n", code: CodeImportInvalid},
|
{name: "placement value", body: "configkit: {imports: [{path: a.yml, placement: item}]}\n", code: CodeImportInvalid},
|
||||||
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||||
@@ -399,9 +474,8 @@ func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testi
|
|||||||
- glob: records/*.yml
|
- glob: records/*.yml
|
||||||
destination: [items]
|
destination: [items]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
- path: absent/*.yml
|
- glob: absent/*.yml
|
||||||
destination: [items]
|
destination: [items]
|
||||||
optional: true
|
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
- path: other.yml
|
- path: other.yml
|
||||||
destination: [other]
|
destination: [other]
|
||||||
@@ -504,7 +578,7 @@ func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
body := "configkit:\n imports:\n - path: child.yml\n" + test.destination
|
body := "configkit:\n imports:\n - path: child.yml\n" + test.destination
|
||||||
declarations := parseImportDeclarationsYAML(t, body)
|
declarations := parseImportSpecificationsYAML(t, body)
|
||||||
if len(declarations) != 1 {
|
if len(declarations) != 1 {
|
||||||
t.Fatalf("declarations = %#v", declarations)
|
t.Fatalf("declarations = %#v", declarations)
|
||||||
}
|
}
|
||||||
@@ -523,7 +597,7 @@ func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
declarations := parseImportDeclarationsYAML(t, `configkit:
|
declarations := parseImportSpecificationsYAML(t, `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: mapping.yml
|
- path: mapping.yml
|
||||||
- glob: records/*.yml
|
- glob: records/*.yml
|
||||||
@@ -555,7 +629,7 @@ func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n"
|
body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n"
|
||||||
parsed, diagnostics := parseImportDeclarationYAML(body)
|
parsed, diagnostics := parseImportSpecificationYAML(body)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath {
|
||||||
t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath)
|
t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath)
|
||||||
}
|
}
|
||||||
@@ -578,12 +652,12 @@ func TestImportDestinationValidationPrecedesOptionalSuppression(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "optional missing path",
|
name: "optional missing path",
|
||||||
body: "configkit:\n imports:\n - path: absent.yml\n optional: true\n destination: null\n",
|
body: "configkit:\n imports:\n - path: absent.yml\n destination: null\n",
|
||||||
path: "/configkit/imports/0/destination",
|
path: "/configkit/imports/0/destination",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "optional unmatched glob",
|
name: "optional unmatched glob",
|
||||||
body: "configkit:\n imports:\n - glob: absent/*.yml\n optional: true\n destination: [extends]\n",
|
body: "configkit:\n imports:\n - glob: absent/*.yml\n destination: [extends]\n",
|
||||||
path: "/configkit/imports/0/destination/0",
|
path: "/configkit/imports/0/destination/0",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -604,7 +678,6 @@ func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: missing.yml
|
- path: missing.yml
|
||||||
optional: true
|
|
||||||
settings: {root: retained}
|
settings: {root: retained}
|
||||||
`)
|
`)
|
||||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
@@ -637,12 +710,42 @@ settings: {root: retained}
|
|||||||
if test.setup != nil {
|
if test.setup != nil {
|
||||||
test.setup(t, directory)
|
test.setup(t, directory)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
required := ""
|
||||||
|
if test.code == CodeImportMissing {
|
||||||
|
required = "\n required: true"
|
||||||
|
}
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\""+required+"\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
required := true
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||||
|
writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n")
|
||||||
|
_, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||||
|
Imports: true, importDefaults: importDefaults{required: &required},
|
||||||
|
})
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("ordinary transitive required default error = %#v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath := writeImportFile(t, directory, "base.yml", "settings: {root: base}\n")
|
||||||
|
underlayPath := writeImportFile(t, directory, "underlay.yml", "configkit: {imports: [{path: absent.yml}]}\n")
|
||||||
|
_, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{
|
||||||
|
main: loadInput{source: File(basePath)},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}},
|
||||||
|
importDefaults: importDefaults{required: &required},
|
||||||
|
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("grouped underlay required default error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||||
t.Run("cycle", func(t *testing.T) {
|
t.Run("cycle", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
@@ -656,6 +759,22 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("cycle through an item scope", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: main.yml}]}\n")
|
||||||
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Primary == nil || len(diagnostic.Related) != 1 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "item.yml") {
|
||||||
|
t.Fatalf("nested item cycle diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("diamond", func(t *testing.T) {
|
t.Run("diamond", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||||
@@ -686,6 +805,192 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n")
|
||||||
|
helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n")
|
||||||
|
|
||||||
|
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("assemble input: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
graph := input.composition.importGraph
|
||||||
|
if len(graph.nodes) != 3 || len(graph.edges) != 2 {
|
||||||
|
t.Fatalf("inclusion graph has %d nodes and %d edges, want 3 and 2", len(graph.nodes), len(graph.edges))
|
||||||
|
}
|
||||||
|
for index, want := range []struct {
|
||||||
|
path string
|
||||||
|
role importContributionRole
|
||||||
|
}{
|
||||||
|
{path: canonicalTestPath(t, mainPath), role: importRoleMain},
|
||||||
|
{path: canonicalTestPath(t, itemPath), role: importRoleItem},
|
||||||
|
{path: canonicalTestPath(t, helperPath), role: importRoleMapping},
|
||||||
|
} {
|
||||||
|
node := graph.nodes[index]
|
||||||
|
if node.canonical != want.path || node.role != want.role || node.sourceID != "source-"+strconv.Itoa(index+1) {
|
||||||
|
t.Errorf("graph node %d = %#v, want %s, %q, source-%d", index, node, want.role, want.path, index+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootScope := input.composition.acquisition[0]
|
||||||
|
if rootScope.owner != graph.nodes[0].key || len(rootScope.contributions) != 2 {
|
||||||
|
t.Fatalf("root acquisition scope = %#v", rootScope)
|
||||||
|
}
|
||||||
|
var item *acquiredContribution
|
||||||
|
for index := range rootScope.contributions {
|
||||||
|
if rootScope.contributions[index].role == importRoleItem {
|
||||||
|
item = &rootScope.contributions[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.nested == nil {
|
||||||
|
t.Fatalf("outer item contribution = %#v", item)
|
||||||
|
}
|
||||||
|
itemScope := item.nested.acquisition[0]
|
||||||
|
if itemScope.owner != item.identity || len(itemScope.contributions) != 2 {
|
||||||
|
t.Fatalf("nested item scope = %#v", itemScope)
|
||||||
|
}
|
||||||
|
for _, contribution := range itemScope.contributions {
|
||||||
|
if contribution.scopeOwner != item.identity {
|
||||||
|
t.Errorf("nested contribution scope owner = %#v, want item %v", contribution.scopeOwner, item.identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if graph.edges[0].importer != graph.nodes[0].key || graph.edges[0].imported != graph.nodes[1].key || graph.edges[0].location.SourceID != "source-1" ||
|
||||||
|
graph.edges[1].importer != graph.nodes[1].key || graph.edges[1].imported != graph.nodes[2].key || graph.edges[1].location.SourceID != "source-2" {
|
||||||
|
t.Fatalf("inclusion edges = %#v", graph.edges)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemContributionsUseDependencyReadySchedules(t *testing.T) {
|
||||||
|
t.Run("outer independent items", func(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: z-item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
- path: a-item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
items: []
|
||||||
|
`)
|
||||||
|
zPath := writeImportFile(t, directory, "z-item.yml", "id: z\n")
|
||||||
|
aPath := writeImportFile(t, directory, "a-item.yml", "id: a\n")
|
||||||
|
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load item imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 2 || result.Config.Items[0].ID != "a" || result.Config.Items[1].ID != "z" {
|
||||||
|
t.Fatalf("scheduled items = %#v", result.Config.Items)
|
||||||
|
}
|
||||||
|
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, zPath), canonicalTestPath(t, aPath)}
|
||||||
|
for index, want := range wantSources {
|
||||||
|
if got := result.Sources()[index].Path; got != want {
|
||||||
|
t.Errorf("acquired source %d = %q, want %q", index, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unequal helper branches", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "item.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: b-parent.yml
|
||||||
|
- path: c-independent.yml
|
||||||
|
id: item
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nid: b\n")
|
||||||
|
writeImportFile(t, directory, "a-dependency.yml", "id: a\n")
|
||||||
|
writeImportFile(t, directory, "c-independent.yml", "id: c\n")
|
||||||
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-4" {
|
||||||
|
t.Fatalf("item helper conflict = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
wantRelated := map[string]bool{"source-3": false, "source-5": false, "source-2": false}
|
||||||
|
if len(diagnostic.Related) != len(wantRelated) {
|
||||||
|
t.Fatalf("related sites = %#v", diagnostic.Related)
|
||||||
|
}
|
||||||
|
for _, site := range diagnostic.Related {
|
||||||
|
if _, expected := wantRelated[site.SourceID]; !expected {
|
||||||
|
t.Errorf("unexpected related site source %q", site.SourceID)
|
||||||
|
}
|
||||||
|
wantRelated[site.SourceID] = true
|
||||||
|
}
|
||||||
|
for sourceID, found := range wantRelated {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("related sites do not include %q", sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("selected main item dependencies", func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
|
||||||
|
writeImportFile(t, directory, "helper.yml", "id: helper\n")
|
||||||
|
options := Options[importTestConfig]{
|
||||||
|
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
|
||||||
|
}
|
||||||
|
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Path.String() != "/items/0/id" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "source-1" {
|
||||||
|
t.Fatalf("main item helper conflict = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: b-parent.yml}, {path: c-independent.yml}]}\nitem: true\n")
|
||||||
|
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n")
|
||||||
|
writeImportFile(t, directory, "a-dependency.yml", "a: true\n")
|
||||||
|
writeImportFile(t, directory, "c-independent.yml", "c: true\n")
|
||||||
|
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("assemble item scope: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
var nested *composedDocument
|
||||||
|
for _, contribution := range input.composition.acquisition[0].contributions {
|
||||||
|
if contribution.role == importRoleItem {
|
||||||
|
nested = contribution.nested
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nested == nil || len(nested.acquisition) != 1 {
|
||||||
|
t.Fatalf("item composition = %#v", nested)
|
||||||
|
}
|
||||||
|
wantIDs := []string{"source-4", "source-3", "source-5", "source-2"}
|
||||||
|
for index, want := range wantIDs {
|
||||||
|
if got := nested.acquisition[0].contributions[index].source.ID; got != want {
|
||||||
|
t.Errorf("local contribution %d = %q, want %q", index, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||||
t.Skip("initial filesystem contract covers Linux and macOS")
|
t.Skip("initial filesystem contract covers Linux and macOS")
|
||||||
@@ -797,7 +1102,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
|
|
||||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||||
@@ -809,7 +1114,7 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
|||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
||||||
}
|
}
|
||||||
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,6 +1149,48 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportDepthBudgetCountsSelectedEdges(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, declaration string
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "no declaration"},
|
||||||
|
{name: "empty paths", declaration: "path: []"},
|
||||||
|
{name: "required empty paths", declaration: "path: []\n required: true"},
|
||||||
|
{name: "missing path", declaration: "path: absent.yml"},
|
||||||
|
{name: "missing directory", declaration: "dir: absent"},
|
||||||
|
{name: "missing glob", declaration: "glob: 'absent/*.yml'"},
|
||||||
|
{name: "selected path", declaration: "path: parts/next.yml", code: CodeImportLimitExceeded},
|
||||||
|
{name: "selected directory", declaration: "dir: parts", code: CodeImportLimitExceeded},
|
||||||
|
{name: "selected glob", declaration: "glob: 'parts/*.yml'", code: CodeImportLimitExceeded},
|
||||||
|
{name: "invalid empty declaration", declaration: "dir: []\n depth: 1", code: CodeImportInvalid},
|
||||||
|
{name: "invalid absent glob", declaration: "glob: '['", code: CodeImportInvalid},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: leaf.yml}]}\n")
|
||||||
|
leaf := "end: true\n"
|
||||||
|
if test.declaration != "" {
|
||||||
|
leaf += "configkit:\n imports:\n - " + test.declaration + "\n"
|
||||||
|
}
|
||||||
|
writeImportFile(t, directory, "leaf.yml", leaf)
|
||||||
|
writeImportFile(t, directory, "parts/next.yml", "next: true\n")
|
||||||
|
limits := defaultImportLimits()
|
||||||
|
limits.depth = 1
|
||||||
|
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, parseImportMain(t, mainPath), nil, limits)
|
||||||
|
if test.code != "" {
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||||
|
t.Fatalf("depth-boundary composition: sources=%#v diagnostics=%#v", sources, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
||||||
@@ -919,16 +1266,16 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseImportDeclarationsYAML(t *testing.T, input string) []importDeclaration {
|
func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
declarations, diagnostics := parseImportDeclarationYAML(input)
|
declarations, diagnostics := parseImportSpecificationYAML(input)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("import parse diagnostics = %#v", diagnostics)
|
t.Fatalf("import parse diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
return declarations
|
return declarations
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseImportDeclarationYAML(input string) ([]importDeclaration, []Diagnostic) {
|
func parseImportSpecificationYAML(input string) ([]importDeclarationSpec, []Diagnostic) {
|
||||||
parsed, diagnostics := parseSource(Bytes("import fixture", []byte(input)), "source-1", defaultParseLimits())
|
parsed, diagnostics := parseSource(Bytes("import fixture", []byte(input)), "source-1", defaultParseLimits())
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, diagnostics
|
return nil, diagnostics
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
||||||
)
|
|
||||||
|
|
||||||
const inheritanceKey = "extends"
|
const inheritanceKey = "extends"
|
||||||
|
|
||||||
@@ -66,13 +62,6 @@ type inheritanceContributor struct {
|
|||||||
trace *inheritanceTrace
|
trace *inheritanceTrace
|
||||||
}
|
}
|
||||||
|
|
||||||
type siblingFrame struct {
|
|
||||||
name string
|
|
||||||
parent string
|
|
||||||
hasParent bool
|
|
||||||
entered bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// expandSiblingDefinitions resolves only the mappings directly in scope.
|
// expandSiblingDefinitions resolves only the mappings directly in scope.
|
||||||
// Nested instructions remain in the returned templates for destination-local
|
// Nested instructions remain in the returned templates for destination-local
|
||||||
// resolution. This private entry point does not participate in public loading.
|
// resolution. This private entry point does not participate in public loading.
|
||||||
@@ -84,202 +73,6 @@ func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, li
|
|||||||
return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil)
|
return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
|
|
||||||
if scope == nil || scope.Kind != document.Mapping {
|
|
||||||
return nil, []Diagnostic{{
|
|
||||||
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
|
|
||||||
Message: "inheritance scope must be a mapping",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
byName := make(map[string]document.Entry, len(scope.Mapping))
|
|
||||||
names := make([]string, 0, len(scope.Mapping))
|
|
||||||
for _, entry := range scope.Mapping {
|
|
||||||
byName[entry.Key] = entry
|
|
||||||
names = append(names, entry.Key)
|
|
||||||
}
|
|
||||||
slices.Sort(names)
|
|
||||||
|
|
||||||
resolved := make(map[string]*document.Node, len(byName))
|
|
||||||
resolvedOrigins := make(map[string]map[string]Origin, len(byName))
|
|
||||||
state := make(map[string]uint8, len(byName))
|
|
||||||
result := &inheritanceScope{}
|
|
||||||
counter := newRetainedNodeCounter(historyNodeLimit)
|
|
||||||
if len(budget) != 0 {
|
|
||||||
counter = budget[0]
|
|
||||||
}
|
|
||||||
retained := 1 // scope mapping node
|
|
||||||
for _, entry := range scope.Mapping {
|
|
||||||
if entry.Value.Kind == document.Mapping {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remaining := limits.nodes - retained - 1
|
|
||||||
if limits.nodes >= 0 && remaining < 0 {
|
|
||||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
|
|
||||||
}
|
|
||||||
count, within := inheritanceLowerBoundCount(entry.Value, remaining)
|
|
||||||
if !within {
|
|
||||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
|
|
||||||
}
|
|
||||||
retained += 1 + count // key and value subtree
|
|
||||||
}
|
|
||||||
for _, name := range names {
|
|
||||||
if byName[name].Value.Kind != document.Mapping || state[name] == 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stack := []siblingFrame{{name: name}}
|
|
||||||
for len(stack) != 0 {
|
|
||||||
frame := &stack[len(stack)-1]
|
|
||||||
entry := byName[frame.name]
|
|
||||||
path := scopePath.Key(frame.name)
|
|
||||||
if !frame.entered {
|
|
||||||
state[frame.name] = 1
|
|
||||||
frame.entered = true
|
|
||||||
reference, found := inheritanceDirective(entry.Value)
|
|
||||||
if found {
|
|
||||||
referencePath := path.Key(inheritanceKey)
|
|
||||||
location := publicLocation(reference.Value.Location)
|
|
||||||
result.controls = append(result.controls, inheritanceControl{
|
|
||||||
path: referencePath, locations: []Location{location},
|
|
||||||
cancelled: reference.Value.Kind == document.Null,
|
|
||||||
})
|
|
||||||
switch reference.Value.Kind {
|
|
||||||
case document.Null:
|
|
||||||
case document.String:
|
|
||||||
frame.parent, frame.hasParent = reference.Value.Text, true
|
|
||||||
parent, exists := byName[frame.parent]
|
|
||||||
if !exists {
|
|
||||||
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceMissing, referencePath, location, nil,
|
|
||||||
"referenced sibling does not exist")}
|
|
||||||
}
|
|
||||||
if parent.Value.Kind != document.Mapping {
|
|
||||||
related := []Location{publicLocation(parent.Value.Location)}
|
|
||||||
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, related,
|
|
||||||
"referenced sibling is not a mapping")}
|
|
||||||
}
|
|
||||||
if state[frame.parent] == 1 {
|
|
||||||
related := []Location{publicLocation(parent.Value.Location)}
|
|
||||||
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location, related,
|
|
||||||
"sibling inheritance contains a cycle")}
|
|
||||||
}
|
|
||||||
if state[frame.parent] == 0 {
|
|
||||||
stack = append(stack, siblingFrame{name: frame.parent})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil,
|
|
||||||
"inheritance reference must be a sibling name or null")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := withoutInheritanceDirective(entry.Value)
|
|
||||||
operationStart, replacementStart := len(result.operations), len(result.replacements)
|
|
||||||
childOrigins := directInheritanceOrigins(payload, path, inputOrigins)
|
|
||||||
if reference, found := inheritanceDirective(entry.Value); found {
|
|
||||||
control := inheritanceControl{
|
|
||||||
path: path.Key(inheritanceKey),
|
|
||||||
locations: []Location{publicLocation(reference.Value.Location)},
|
|
||||||
cancelled: reference.Value.Kind == document.Null,
|
|
||||||
}
|
|
||||||
controlOrigins := directInheritanceOrigins(reference.Value, control.path, inputOrigins)
|
|
||||||
result.operations = append(result.operations, inheritanceOperation{
|
|
||||||
control: &control, controlOrigin: controlOrigins[control.path.String()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if limits.nodes >= 0 {
|
|
||||||
remaining := limits.nodes - retained - 1 // the sibling key
|
|
||||||
within := false
|
|
||||||
if frame.hasParent {
|
|
||||||
within = inheritanceOverlayLowerBoundWithin(resolved[frame.parent], payload, remaining)
|
|
||||||
} else {
|
|
||||||
within = inheritanceLowerBoundWithin(payload, remaining)
|
|
||||||
}
|
|
||||||
if !within {
|
|
||||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(path, entry.Value.Location)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if frame.hasParent {
|
|
||||||
parent := resolved[frame.parent]
|
|
||||||
merged := document.Clone(parent)
|
|
||||||
control := inheritanceDirectiveLocation(entry.Value)
|
|
||||||
transfer := inheritanceTransfer{
|
|
||||||
from: scopePath.Key(frame.parent), to: path,
|
|
||||||
reference: path.Key(inheritanceKey), locations: []Location{control},
|
|
||||||
}
|
|
||||||
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[frame.parent], transfer.from, path, transfer)
|
|
||||||
mergeInheritanceOrigin(mergedOrigins, path, childOrigins[path.String()])
|
|
||||||
mergeRecursiveMappings(merged, payload, path, new(mappingIndex), recursiveMergeHooks{
|
|
||||||
added: func(childPath Path, incoming *document.Node) {
|
|
||||||
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
|
||||||
},
|
|
||||||
combined: func(childPath Path, _ *document.Node) {
|
|
||||||
mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()])
|
|
||||||
},
|
|
||||||
replaced: func(childPath Path, existing, incoming *document.Node) {
|
|
||||||
previous := displacedOrigins(existing, mergedOrigins, childPath)
|
|
||||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
|
||||||
path: childPath.clone(), before: existing, after: incoming,
|
|
||||||
previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]),
|
|
||||||
})
|
|
||||||
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
|
||||||
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
merged.Location = entry.Value.Location
|
|
||||||
resolved[frame.name] = merged
|
|
||||||
resolvedOrigins[frame.name] = mergedOrigins
|
|
||||||
result.transfers = append(result.transfers, transfer)
|
|
||||||
result.operations = append(result.operations, inheritanceOperation{
|
|
||||||
transfer: &transfer, parent: parent, effective: merged,
|
|
||||||
parentOrigins: resolvedOrigins[frame.parent], effectiveOrigins: mergedOrigins,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
resolved[frame.name] = document.Clone(payload)
|
|
||||||
resolvedOrigins[frame.name] = childOrigins
|
|
||||||
}
|
|
||||||
count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes)
|
|
||||||
retained += 1 + count
|
|
||||||
for _, operation := range result.operations[operationStart:] {
|
|
||||||
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
|
||||||
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, replacement := range result.replacements[replacementStart:] {
|
|
||||||
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
|
||||||
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state[frame.name] = 2
|
|
||||||
stack = stack[:len(stack)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
root := *scope
|
|
||||||
root.Mapping = make([]document.Entry, len(scope.Mapping))
|
|
||||||
rootOrigin := directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: scope.Location}, scopePath, inputOrigins)
|
|
||||||
result.origins = rootOrigin
|
|
||||||
for index, entry := range scope.Mapping {
|
|
||||||
root.Mapping[index] = entry
|
|
||||||
if resolved[entry.Key] != nil {
|
|
||||||
root.Mapping[index].Value = resolved[entry.Key]
|
|
||||||
for pointer, origin := range resolvedOrigins[entry.Key] {
|
|
||||||
result.origins[pointer] = origin
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
root.Mapping[index].Value = document.Clone(entry.Value)
|
|
||||||
for pointer, origin := range directInheritanceOrigins(entry.Value, scopePath.Key(entry.Key), inputOrigins) {
|
|
||||||
result.origins[pointer] = origin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.root = &root
|
|
||||||
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
|
||||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath, scope.Location)}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func inheritanceDirective(node *document.Node) (document.Entry, bool) {
|
func inheritanceDirective(node *document.Node) (document.Entry, bool) {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
if entry.Key == inheritanceKey {
|
if entry.Key == inheritanceKey {
|
||||||
|
|||||||
259
internal/engine/inheritance_schedule.go
Normal file
259
internal/engine/inheritance_schedule.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inheritanceTemplate struct {
|
||||||
|
name string
|
||||||
|
entry document.Entry
|
||||||
|
path Path
|
||||||
|
parent int
|
||||||
|
hasParent bool
|
||||||
|
hasDirective bool
|
||||||
|
cancelled bool
|
||||||
|
directive document.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type inheritanceGraph struct {
|
||||||
|
scope *document.Node
|
||||||
|
scopePath Path
|
||||||
|
nodes []inheritanceTemplate
|
||||||
|
edges []dependencyEdge
|
||||||
|
byName map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyzeInheritanceGraph retains only direct mapping siblings as graph nodes.
|
||||||
|
// It validates declarations separately from applying their templates, leaving
|
||||||
|
// nested scopes for the existing destination-local walk.
|
||||||
|
func analyzeInheritanceGraph(scope *document.Node, scopePath Path) (*inheritanceGraph, []Diagnostic) {
|
||||||
|
if scope == nil || scope.Kind != document.Mapping {
|
||||||
|
return nil, []Diagnostic{{
|
||||||
|
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
|
||||||
|
Message: "inheritance scope must be a mapping",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
graph := &inheritanceGraph{
|
||||||
|
scope: scope, scopePath: scopePath,
|
||||||
|
byName: make(map[string]int, len(scope.Mapping)),
|
||||||
|
}
|
||||||
|
entries := make(map[string]document.Entry, len(scope.Mapping))
|
||||||
|
for _, entry := range scope.Mapping {
|
||||||
|
entries[entry.Key] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(scope.Mapping))
|
||||||
|
for name, entry := range entries {
|
||||||
|
if entry.Value.Kind == document.Mapping {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
for _, name := range names {
|
||||||
|
index := len(graph.nodes)
|
||||||
|
graph.byName[name] = index
|
||||||
|
graph.nodes = append(graph.nodes, inheritanceTemplate{
|
||||||
|
name: name, entry: entries[name], path: scopePath.Key(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
index := graph.byName[name]
|
||||||
|
node := &graph.nodes[index]
|
||||||
|
reference, found := inheritanceDirective(node.entry.Value)
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.hasDirective = true
|
||||||
|
node.directive = reference
|
||||||
|
referencePath := node.path.Key(inheritanceKey)
|
||||||
|
location := publicLocation(reference.Value.Location)
|
||||||
|
switch reference.Value.Kind {
|
||||||
|
case document.Null:
|
||||||
|
node.cancelled = true
|
||||||
|
case document.String:
|
||||||
|
parent, exists := entries[reference.Value.Text]
|
||||||
|
if !exists {
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceMissing, referencePath, location, nil,
|
||||||
|
"referenced sibling does not exist")}
|
||||||
|
}
|
||||||
|
if parent.Value.Kind != document.Mapping {
|
||||||
|
related := []Location{publicLocation(parent.Value.Location)}
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, related,
|
||||||
|
"referenced sibling is not a mapping")}
|
||||||
|
}
|
||||||
|
node.parent = graph.byName[reference.Value.Text]
|
||||||
|
node.hasParent = true
|
||||||
|
graph.edges = append(graph.edges, dependencyEdge{prerequisite: node.parent, dependent: index})
|
||||||
|
default:
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil,
|
||||||
|
"inheritance reference must be a sibling name or null")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return graph, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (graph *inheritanceGraph) schedule() ([]int, []Diagnostic) {
|
||||||
|
keys := make([]dependencyKey, len(graph.nodes))
|
||||||
|
for i, node := range graph.nodes {
|
||||||
|
keys[i] = dependencyKey{"definition", graph.scopePath.String(), node.path.String()}
|
||||||
|
}
|
||||||
|
order, cycle := scheduleDependencies(keys, graph.edges)
|
||||||
|
if len(cycle) == 0 {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := graph.nodes[cycle[0]]
|
||||||
|
child := graph.nodes[cycle[1]]
|
||||||
|
referencePath := child.path.Key(inheritanceKey)
|
||||||
|
location := publicLocation(child.directive.Value.Location)
|
||||||
|
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location,
|
||||||
|
[]Location{publicLocation(parent.entry.Value.Location)}, "sibling inheritance contains a cycle")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
|
||||||
|
graph, diagnostics := analyzeInheritanceGraph(scope, scopePath)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
order, diagnostics := graph.schedule()
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
return applyInheritanceTemplates(graph, order, limits, inputOrigins, budget...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyInheritanceTemplates builds detached templates in the supplied
|
||||||
|
// dependency-ready order while retaining the existing origins, merge history,
|
||||||
|
// and node-accounting evidence. The root keeps its authored mapping order.
|
||||||
|
func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
|
||||||
|
resolved := make([]*document.Node, len(graph.nodes))
|
||||||
|
resolvedOrigins := make([]map[string]Origin, len(graph.nodes))
|
||||||
|
result := &inheritanceScope{}
|
||||||
|
counter := newRetainedNodeCounter(historyNodeLimit)
|
||||||
|
if len(budget) != 0 {
|
||||||
|
counter = budget[0]
|
||||||
|
}
|
||||||
|
retained := 1 // scope mapping node
|
||||||
|
for _, entry := range graph.scope.Mapping {
|
||||||
|
if entry.Value.Kind == document.Mapping {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining := limits.nodes - retained - 1
|
||||||
|
if limits.nodes >= 0 && remaining < 0 {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath.Key(entry.Key), entry.Value.Location)}
|
||||||
|
}
|
||||||
|
count, within := inheritanceLowerBoundCount(entry.Value, remaining)
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath.Key(entry.Key), entry.Value.Location)}
|
||||||
|
}
|
||||||
|
retained += 1 + count
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, index := range order {
|
||||||
|
node := graph.nodes[index]
|
||||||
|
payload := withoutInheritanceDirective(node.entry.Value)
|
||||||
|
operationStart, replacementStart := len(result.operations), len(result.replacements)
|
||||||
|
childOrigins := directInheritanceOrigins(payload, node.path, inputOrigins)
|
||||||
|
if node.hasDirective {
|
||||||
|
control := inheritanceControl{
|
||||||
|
path: node.path.Key(inheritanceKey),
|
||||||
|
locations: []Location{publicLocation(node.directive.Value.Location)},
|
||||||
|
cancelled: node.cancelled,
|
||||||
|
}
|
||||||
|
result.controls = append(result.controls, control)
|
||||||
|
controlOrigins := directInheritanceOrigins(node.directive.Value, control.path, inputOrigins)
|
||||||
|
result.operations = append(result.operations, inheritanceOperation{
|
||||||
|
control: &control, controlOrigin: controlOrigins[control.path.String()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if limits.nodes >= 0 {
|
||||||
|
remaining := limits.nodes - retained - 1 // the sibling key
|
||||||
|
within := inheritanceLowerBoundWithin(payload, remaining)
|
||||||
|
if node.hasParent {
|
||||||
|
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], payload, remaining)
|
||||||
|
}
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.hasParent {
|
||||||
|
parent := resolved[node.parent]
|
||||||
|
merged := document.Clone(parent)
|
||||||
|
transfer := inheritanceTransfer{
|
||||||
|
from: graph.nodes[node.parent].path, to: node.path,
|
||||||
|
reference: node.path.Key(inheritanceKey),
|
||||||
|
locations: []Location{publicLocation(node.directive.Value.Location)},
|
||||||
|
}
|
||||||
|
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[node.parent], transfer.from, node.path, transfer)
|
||||||
|
mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()])
|
||||||
|
mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{
|
||||||
|
added: func(childPath Path, incoming *document.Node) {
|
||||||
|
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
||||||
|
},
|
||||||
|
combined: func(childPath Path, _ *document.Node) {
|
||||||
|
mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()])
|
||||||
|
},
|
||||||
|
replaced: func(childPath Path, existing, incoming *document.Node) {
|
||||||
|
previous := displacedOrigins(existing, mergedOrigins, childPath)
|
||||||
|
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||||
|
path: childPath.clone(), before: existing, after: incoming,
|
||||||
|
previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]),
|
||||||
|
})
|
||||||
|
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
||||||
|
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
merged.Location = node.entry.Value.Location
|
||||||
|
resolved[index], resolvedOrigins[index] = merged, mergedOrigins
|
||||||
|
result.transfers = append(result.transfers, transfer)
|
||||||
|
result.operations = append(result.operations, inheritanceOperation{
|
||||||
|
transfer: &transfer, parent: parent, effective: merged,
|
||||||
|
parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolved[index] = document.Clone(payload)
|
||||||
|
resolvedOrigins[index] = childOrigins
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes)
|
||||||
|
retained += 1 + count
|
||||||
|
for _, operation := range result.operations[operationStart:] {
|
||||||
|
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
||||||
|
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, replacement := range result.replacements[replacementStart:] {
|
||||||
|
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
||||||
|
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root := *graph.scope
|
||||||
|
root.Mapping = make([]document.Entry, len(graph.scope.Mapping))
|
||||||
|
result.origins = directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: graph.scope.Location}, graph.scopePath, inputOrigins)
|
||||||
|
for i, entry := range graph.scope.Mapping {
|
||||||
|
root.Mapping[i] = entry
|
||||||
|
if index, found := graph.byName[entry.Key]; found {
|
||||||
|
root.Mapping[i].Value = resolved[index]
|
||||||
|
for pointer, origin := range resolvedOrigins[index] {
|
||||||
|
result.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root.Mapping[i].Value = document.Clone(entry.Value)
|
||||||
|
for pointer, origin := range directInheritanceOrigins(entry.Value, graph.scopePath.Key(entry.Key), inputOrigins) {
|
||||||
|
result.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.root = &root
|
||||||
|
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath, graph.scope.Location)}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
170
internal/engine/inheritance_schedule_test.go
Normal file
170
internal/engine/inheritance_schedule_test.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInheritanceGraphSchedulesDirectRelationshipsAndAppliesTemplates(t *testing.T) {
|
||||||
|
root := parseCompositionDocument(t, "schedule", `
|
||||||
|
child:
|
||||||
|
extends: base
|
||||||
|
own: child-value
|
||||||
|
base:
|
||||||
|
inherited: base-value
|
||||||
|
cleared:
|
||||||
|
extends: null
|
||||||
|
own: cleared-value
|
||||||
|
scalar: kept
|
||||||
|
`)
|
||||||
|
original := document.Clone(root)
|
||||||
|
graph, diagnostics := analyzeInheritanceGraph(root, Path{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("analyze diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if len(graph.edges) != 1 {
|
||||||
|
t.Fatalf("edges = %#v, want one direct parent edge", graph.edges)
|
||||||
|
}
|
||||||
|
order, diagnostics := graph.schedule()
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("schedule diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
orderNames := make([]string, len(order))
|
||||||
|
for i, index := range order {
|
||||||
|
orderNames[i] = graph.nodes[index].name
|
||||||
|
}
|
||||||
|
if want := []string{"base", "child", "cleared"}; !reflect.DeepEqual(orderNames, want) {
|
||||||
|
t.Fatalf("mapping schedule = %v, want %v", orderNames, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, diagnostics := applyInheritanceTemplates(graph, order, defaultProjectionLimits(), nil)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("apply diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(root, original) {
|
||||||
|
t.Fatal("applying templates mutated the input mapping")
|
||||||
|
}
|
||||||
|
if len(result.controls) != 2 || result.controls[0].path.String() != "/child/extends" || result.controls[0].cancelled ||
|
||||||
|
result.controls[1].path.String() != "/cleared/extends" || !result.controls[1].cancelled {
|
||||||
|
t.Fatalf("controls = %#v, want scheduled ordinary and null relationships", result.controls)
|
||||||
|
}
|
||||||
|
if len(result.transfers) != 1 || result.transfers[0].from.String() != "/base" || result.transfers[0].to.String() != "/child" {
|
||||||
|
t.Fatalf("transfers = %#v, want the direct parent transfer", result.transfers)
|
||||||
|
}
|
||||||
|
if got, want := result.root.Mapping[0].Key, "child"; got != want {
|
||||||
|
t.Fatalf("root order begins with %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
child := result.root.Mapping[0].Value
|
||||||
|
inherited, found := documentAtPath(child, Path{}.Key("inherited"))
|
||||||
|
if !found || inherited.Text != "base-value" {
|
||||||
|
t.Fatalf("inherited value = %#v, found %t", inherited, found)
|
||||||
|
}
|
||||||
|
childValue, found := documentAtPath(child, Path{}.Key("own"))
|
||||||
|
if !found || childValue.Text != "child-value" {
|
||||||
|
t.Fatalf("child value = %#v, found %t", childValue, found)
|
||||||
|
}
|
||||||
|
cleared := result.root.Mapping[2].Value
|
||||||
|
clearedValue, found := documentAtPath(cleared, Path{}.Key("own"))
|
||||||
|
if !found || clearedValue.Text != "cleared-value" {
|
||||||
|
t.Fatalf("null relationship value = %#v, found %t", clearedValue, found)
|
||||||
|
}
|
||||||
|
if _, found := inheritanceDirective(cleared); found {
|
||||||
|
t.Fatal("null relationship directive remained in the template")
|
||||||
|
}
|
||||||
|
|
||||||
|
child.Mapping[0].Value.Text = "changed-result"
|
||||||
|
inherited, found = documentAtPath(root.Mapping[1].Value, Path{}.Key("inherited"))
|
||||||
|
if !found || inherited.Text != "base-value" {
|
||||||
|
t.Fatalf("mutating result changed input: inherited value = %#v, found %t", inherited, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceGraphReportsCycleAtAuthoredRelationship(t *testing.T) {
|
||||||
|
root := parseCompositionDocument(t, "cycle-source", `alpha:
|
||||||
|
extends: beta
|
||||||
|
beta:
|
||||||
|
extends: alpha
|
||||||
|
`)
|
||||||
|
graph, diagnostics := analyzeInheritanceGraph(root, Path{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("analyze diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = graph.schedule()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceCycle {
|
||||||
|
t.Fatalf("cycle diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
diagnostic := diagnostics[0]
|
||||||
|
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "cycle-source" {
|
||||||
|
t.Fatalf("cycle primary = %#v, want authored source", diagnostic.Primary)
|
||||||
|
}
|
||||||
|
if diagnostic.Primary.Line != 4 {
|
||||||
|
t.Fatalf("cycle primary line = %d, want line 4", diagnostic.Primary.Line)
|
||||||
|
}
|
||||||
|
if len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "cycle-source" {
|
||||||
|
t.Fatalf("cycle related sites = %#v, want the referenced mapping", diagnostic.Related)
|
||||||
|
}
|
||||||
|
if diagnostic.Message != "sibling inheritance contains a cycle" {
|
||||||
|
t.Fatalf("cycle message exposes unexpected details: %q", diagnostic.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceExpansionInterleavesUnequalBranches(t *testing.T) {
|
||||||
|
root := parseCompositionDocument(t, "branches", `a-grandchild:
|
||||||
|
extends: z-child
|
||||||
|
own: grandchild
|
||||||
|
b-base:
|
||||||
|
base: b
|
||||||
|
c-base:
|
||||||
|
base: c
|
||||||
|
d-child:
|
||||||
|
extends: c-base
|
||||||
|
own: d
|
||||||
|
z-child:
|
||||||
|
extends: b-base
|
||||||
|
own: z
|
||||||
|
`)
|
||||||
|
result, diagnostics := expandInheritanceTree(root, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("expand diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
got := make([]string, len(result.transfers))
|
||||||
|
for i, transfer := range result.transfers {
|
||||||
|
got[i] = transfer.from.String() + " -> " + transfer.to.String()
|
||||||
|
}
|
||||||
|
want := []string{"/c-base -> /d-child", "/b-base -> /z-child", "/z-child -> /a-grandchild"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("transfer order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if value, found := documentAtPath(result.root, Path{}.Key("a-grandchild").Key("base")); !found || value.Text != "b" {
|
||||||
|
t.Fatalf("grandchild inherited value = %#v, found %t", value, found)
|
||||||
|
}
|
||||||
|
if value, found := documentAtPath(result.root, Path{}.Key("d-child").Key("base")); !found || value.Text != "c" {
|
||||||
|
t.Fatalf("independent branch inherited value = %#v, found %t", value, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceGraphValidatesRelationshipKinds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "missing sibling", text: "child:\n extends: absent\n", code: CodeInheritanceMissing},
|
||||||
|
{name: "non-mapping sibling", text: "base: scalar\nchild:\n extends: base\n", code: CodeInheritanceInvalid},
|
||||||
|
{name: "invalid relationship", text: "child:\n extends: 3\n", code: CodeInheritanceInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := parseCompositionDocument(t, "invalid", test.text)
|
||||||
|
_, diagnostics := analyzeInheritanceGraph(root, Path{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
||||||
|
t.Fatalf("diagnostics = %#v, want code %q", diagnostics, test.code)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "invalid" {
|
||||||
|
t.Fatalf("primary site = %#v, want authored declaration", diagnostics[0].Primary)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type Underlay struct{ Inputs []Input }
|
|||||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||||
type LayeredOptions[T any] struct {
|
type LayeredOptions[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
|
importDefaults importDefaults
|
||||||
SourceVersionMode sourceVersionMode
|
SourceVersionMode sourceVersionMode
|
||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
@@ -40,6 +41,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
|||||||
destination: main.Destination.clone(), placementMode: main.placementMode,
|
destination: main.Destination.clone(), placementMode: main.placementMode,
|
||||||
},
|
},
|
||||||
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||||
|
importDefaults: snapshotImportDefaults(options.importDefaults),
|
||||||
overlay: options.Overlay,
|
overlay: options.Overlay,
|
||||||
overlayFilesystem: options.OverlayFilesystem,
|
overlayFilesystem: options.OverlayFilesystem,
|
||||||
overrides: append([]Override(nil), options.Overrides...),
|
overrides: append([]Override(nil), options.Overrides...),
|
||||||
|
|||||||
@@ -21,18 +21,21 @@ type Definition[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
Overlay string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
SourceVersionMode sourceVersionMode
|
SourceVersionMode sourceVersionMode
|
||||||
destination Path
|
importDefaults importDefaults
|
||||||
placementMode placementMode
|
destination Path
|
||||||
sequencePolicy sequencePolicy
|
placementMode placementMode
|
||||||
|
sequencePolicy sequencePolicy
|
||||||
|
deferAssembly bool
|
||||||
|
importDefaultsValidated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||||
@@ -151,7 +154,7 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
|
|||||||
definition.sequenceRegistrations = nil
|
definition.sequenceRegistrations = nil
|
||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
||||||
definition: definition, sequencePolicy: policy, overlay: options.Overlay,
|
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,
|
||||||
overrides: options.Overrides, ordinary: true,
|
overrides: options.Overrides, ordinary: true,
|
||||||
sourceVersionMode: options.SourceVersionMode,
|
sourceVersionMode: options.SourceVersionMode,
|
||||||
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
|
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.placementMode),
|
||||||
@@ -163,6 +166,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
|
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
|
||||||
|
mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||||
mainOptions.sequencePolicy = request.sequencePolicy
|
mainOptions.sequencePolicy = request.sequencePolicy
|
||||||
mainOptions.SourceVersionMode = request.sourceVersionMode
|
mainOptions.SourceVersionMode = request.sourceVersionMode
|
||||||
mainOptions.Definition.Version = request.protectionVersion()
|
mainOptions.Definition.Version = request.protectionVersion()
|
||||||
@@ -182,6 +186,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
versionState := &sourceVersionState{
|
versionState := &sourceVersionState{
|
||||||
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
||||||
|
importDefaults: snapshotImportDefaults(request.importDefaults),
|
||||||
}
|
}
|
||||||
accumulator.versionState = versionState
|
accumulator.versionState = versionState
|
||||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||||
|
|||||||
@@ -23,37 +23,53 @@ type assembledInput struct {
|
|||||||
// boundary for later scoped assembly and evidence.
|
// boundary for later scoped assembly and evidence.
|
||||||
type acquiredContribution struct {
|
type acquiredContribution struct {
|
||||||
source SourceInfo
|
source SourceInfo
|
||||||
|
identity importNodeKey
|
||||||
|
role importContributionRole
|
||||||
|
scopeOwner importNodeKey
|
||||||
payload *document.Node
|
payload *document.Node
|
||||||
document *document.Node
|
document *document.Node
|
||||||
destination Path
|
destination Path
|
||||||
relativeDestination Path
|
relativeDestination Path
|
||||||
processing *sourceVersionProcessingResult
|
processing *sourceVersionProcessingResult
|
||||||
nested *composedDocument
|
nested *composedDocument
|
||||||
|
pendingItem *acquisitionScope
|
||||||
|
placementCharged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// acquisitionScope is the ordered strict mapping scope produced by one input
|
// acquisitionScope is the ordered strict mapping scope produced by one input
|
||||||
// and its ordinary imports.
|
// and its ordinary imports.
|
||||||
type acquisitionScope struct {
|
type acquisitionScope struct {
|
||||||
|
owner importNodeKey
|
||||||
destination Path
|
destination Path
|
||||||
contributions []acquiredContribution
|
contributions []acquiredContribution
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope {
|
func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope {
|
||||||
cloned := acquisitionScope{destination: scope.destination.clone()}
|
cloned := acquisitionScope{owner: scope.owner, destination: scope.destination.clone()}
|
||||||
if scope.contributions != nil {
|
if scope.contributions != nil {
|
||||||
cloned.contributions = make([]acquiredContribution, len(scope.contributions))
|
cloned.contributions = make([]acquiredContribution, len(scope.contributions))
|
||||||
for index, contribution := range scope.contributions {
|
for index, contribution := range scope.contributions {
|
||||||
cloned.contributions[index] = acquiredContribution{
|
cloned.contributions[index] = acquiredContribution{
|
||||||
source: cloneSourceInfo(contribution.source), payload: contribution.payload,
|
source: cloneSourceInfo(contribution.source), identity: contribution.identity,
|
||||||
|
role: contribution.role, scopeOwner: contribution.scopeOwner, payload: contribution.payload,
|
||||||
document: contribution.document, destination: contribution.destination.clone(),
|
document: contribution.document, destination: contribution.destination.clone(),
|
||||||
relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing,
|
relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing,
|
||||||
nested: cloneComposedDocument(contribution.nested),
|
nested: cloneComposedDocument(contribution.nested), pendingItem: cloneAcquisitionScopePointer(contribution.pendingItem),
|
||||||
|
placementCharged: contribution.placementCharged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneAcquisitionScopePointer(scope *acquisitionScope) *acquisitionScope {
|
||||||
|
if scope == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := cloneAcquisitionScope(*scope)
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
|
func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
|
||||||
if scopes == nil {
|
if scopes == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -121,12 +137,18 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if options.Imports {
|
if options.Imports {
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
if options.deferAssembly {
|
||||||
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
||||||
|
} else {
|
||||||
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
||||||
|
}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
if input.composition != nil {
|
||||||
input.document = input.composition.root
|
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||||
|
input.document = input.composition.root
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
input.sources = sourceInfos(parsed.source)
|
input.sources = sourceInfos(parsed.source)
|
||||||
if versionProcessing != nil {
|
if versionProcessing != nil {
|
||||||
@@ -145,10 +167,13 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
}
|
}
|
||||||
localPayload := input.document
|
localPayload := input.document
|
||||||
input.document = placed
|
input.document = placed
|
||||||
|
identity := importKeyForMainSource(parsed.source)
|
||||||
input.acquisition = []acquisitionScope{{
|
input.acquisition = []acquisitionScope{{
|
||||||
|
owner: identity,
|
||||||
destination: destination.clone(),
|
destination: destination.clone(),
|
||||||
contributions: []acquiredContribution{{
|
contributions: []acquiredContribution{{
|
||||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||||
|
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||||
document: placed, destination: destination.clone(), processing: versionProcessing,
|
document: placed, destination: destination.clone(), processing: versionProcessing,
|
||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type loadRequest[T any] struct {
|
|||||||
main loadInput
|
main loadInput
|
||||||
underlays []loadGroup
|
underlays []loadGroup
|
||||||
definition Definition[T]
|
definition Definition[T]
|
||||||
|
importDefaults importDefaults
|
||||||
sequencePolicy sequencePolicy
|
sequencePolicy sequencePolicy
|
||||||
sourceVersionMode sourceVersionMode
|
sourceVersionMode sourceVersionMode
|
||||||
effectiveVersion *VersionRule
|
effectiveVersion *VersionRule
|
||||||
@@ -39,6 +40,8 @@ type loadGroup struct{ inputs []loadInput }
|
|||||||
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
|
||||||
request.ordinary = false
|
request.ordinary = false
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
|
request.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||||
|
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
|
||||||
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
|
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
|
||||||
request.sequencePolicy = policy
|
request.sequencePolicy = policy
|
||||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||||
@@ -46,6 +49,7 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
|||||||
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
||||||
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
||||||
|
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
||||||
}})
|
}})
|
||||||
request.main.destination = mainOptions.destination
|
request.main.destination = mainOptions.destination
|
||||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||||
@@ -53,7 +57,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
|||||||
for groupIndex := range request.underlays {
|
for groupIndex := range request.underlays {
|
||||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||||
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode}})
|
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{
|
||||||
|
Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode,
|
||||||
|
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
||||||
|
}})
|
||||||
input.destination = inputOptions.destination
|
input.destination = inputOptions.destination
|
||||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||||
}
|
}
|
||||||
@@ -158,14 +165,29 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
|||||||
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
||||||
visited := make(map[resourceIdentity]importRoute)
|
visited := make(map[resourceIdentity]importRoute)
|
||||||
hostToken := &namespaceToken{}
|
hostToken := &namespaceToken{}
|
||||||
|
nativeAnchors := make(map[*namespaceToken]string)
|
||||||
|
for inputIndex, contribution := range group.inputs {
|
||||||
|
if token := nativeTokenForLoadInput(contribution); token != nil {
|
||||||
|
if _, exists := nativeAnchors[token]; !exists {
|
||||||
|
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var scopes []acquisitionScope
|
var scopes []acquisitionScope
|
||||||
|
inclusionGraph := newImportInclusionGraph()
|
||||||
var sources []SourceInfo
|
var sources []SourceInfo
|
||||||
var contexts map[string]ResourceContext
|
var contexts map[string]ResourceContext
|
||||||
for _, contribution := range group.inputs {
|
for inputIndex, contribution := range group.inputs {
|
||||||
options := Options[struct{}]{
|
options := Options[struct{}]{
|
||||||
Imports: contribution.imports, Filesystem: contribution.filesystem,
|
Imports: contribution.imports, Filesystem: contribution.filesystem,
|
||||||
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
|
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
|
||||||
placementMode: contribution.placementMode, sequencePolicy: policy,
|
placementMode: contribution.placementMode, sequencePolicy: policy, deferAssembly: true,
|
||||||
|
importDefaults: func() importDefaults {
|
||||||
|
if versionState == nil {
|
||||||
|
return importDefaults{}
|
||||||
|
}
|
||||||
|
return snapshotImportDefaults(versionState.importDefaults)
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
|
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
|
||||||
for _, source := range acquired.sources {
|
for _, source := range acquired.sources {
|
||||||
@@ -177,16 +199,37 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
|||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
|
return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
|
||||||
}
|
}
|
||||||
if acquired.document == nil {
|
if acquired.document == nil && len(acquired.acquisition) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scopes = append(scopes, cloneAcquisitionScopes(acquired.acquisition)...)
|
anchors := importNamespaceAnchors{
|
||||||
|
kind: "host", host: explicitNamespaceAnchor(index, 0),
|
||||||
|
input: explicitNamespaceAnchor(index, inputIndex+1),
|
||||||
|
}
|
||||||
|
if token := nativeTokenForLoadInput(contribution); token != nil {
|
||||||
|
anchors.kind = "native"
|
||||||
|
anchors.native = nativeAnchors[token]
|
||||||
|
}
|
||||||
|
inputScopes := cloneAcquisitionScopes(acquired.acquisition)
|
||||||
|
inputGraph := newImportInclusionGraph()
|
||||||
|
if acquired.composition != nil {
|
||||||
|
inputGraph = acquired.composition.importGraph
|
||||||
|
}
|
||||||
|
inputGraph, keyMap := reanchorImportGraph(inputGraph, anchors)
|
||||||
|
for scopeIndex := range inputScopes {
|
||||||
|
reanchorAcquisitionScope(&inputScopes[scopeIndex], keyMap, anchors)
|
||||||
|
}
|
||||||
|
scopes = append(scopes, inputScopes...)
|
||||||
|
mergeImportInclusionGraph(&inclusionGraph, inputGraph)
|
||||||
|
}
|
||||||
|
groupScope := scheduleAcquisitionGroup(scopes, inclusionGraph)
|
||||||
|
traversal := importTraversal{accounting: accounting, limits: defaultImportLimits(), rule: rule, importGraph: inclusionGraph}
|
||||||
|
if diagnostics := traversal.composePendingItems(&groupScope); len(diagnostics) != 0 {
|
||||||
|
return nil, sources, contexts, diagnostics
|
||||||
}
|
}
|
||||||
var documents []*document.Node
|
var documents []*document.Node
|
||||||
for _, scope := range scopes {
|
for _, contribution := range groupScope.contributions {
|
||||||
for _, contribution := range scope.contributions {
|
documents = append(documents, contribution.document)
|
||||||
documents = append(documents, contribution.document)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(documents) == 0 {
|
if len(documents) == 0 {
|
||||||
return nil, sources, contexts, nil
|
return nil, sources, contexts, nil
|
||||||
@@ -197,6 +240,18 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
|||||||
}
|
}
|
||||||
if composition != nil && rule != nil {
|
if composition != nil && rule != nil {
|
||||||
ranges := indexSequenceRanges(composition.sequenceRanges)
|
ranges := indexSequenceRanges(composition.sequenceRanges)
|
||||||
|
for sourceIndex, document := range documents {
|
||||||
|
if groupScope.contributions[sourceIndex].role == importRoleMain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
|
||||||
|
location := publicLocation(node.Location)
|
||||||
|
return nil, sources, contexts, []Diagnostic{{
|
||||||
|
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
for sourceIndex, document := range documents {
|
for sourceIndex, document := range documents {
|
||||||
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
|
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
|
||||||
location := publicLocation(node.Location)
|
location := publicLocation(node.Location)
|
||||||
@@ -209,13 +264,14 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
|||||||
}
|
}
|
||||||
composition.inputBytes = accounting.bytes - initialBytes
|
composition.inputBytes = accounting.bytes - initialBytes
|
||||||
composition.inputNodes = accounting.nodes - initialNodes
|
composition.inputNodes = accounting.nodes - initialNodes
|
||||||
composition.acquisition = cloneAcquisitionScopes(scopes)
|
groupScope.owner = importNodeKey{namespaceKind: "group", namespaceAnchor: explicitNamespaceAnchor(index, 0), resourceKind: "scope"}
|
||||||
var projectionScope acquisitionScope
|
for contributionIndex := range groupScope.contributions {
|
||||||
for _, scope := range scopes {
|
groupScope.contributions[contributionIndex].scopeOwner = groupScope.owner
|
||||||
projectionScope.contributions = append(projectionScope.contributions, scope.contributions...)
|
|
||||||
}
|
}
|
||||||
projectNestedEvidence(composition, projectionScope)
|
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(groupScope)}
|
||||||
projectAcquiredDestinations(composition, projectionScope, sources)
|
composition.importGraph = inclusionGraph.clone()
|
||||||
|
projectNestedEvidence(composition, groupScope)
|
||||||
|
projectAcquiredDestinations(composition, groupScope, sources)
|
||||||
for pointer, origin := range composition.origins {
|
for pointer, origin := range composition.origins {
|
||||||
origin.Layer = LayerUnderlay
|
origin.Layer = LayerUnderlay
|
||||||
composition.origins[pointer] = origin
|
composition.origins[pointer] = origin
|
||||||
@@ -225,3 +281,13 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
|||||||
}
|
}
|
||||||
return composition, sources, contexts, nil
|
return composition, sources, contexts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nativeTokenForLoadInput(input loadInput) *namespaceToken {
|
||||||
|
if input.source.kind == sourceFilesystem && input.source.namespace != nil {
|
||||||
|
return input.source.namespace.token
|
||||||
|
}
|
||||||
|
if input.filesystem != nil && input.filesystem.FS != nil && input.filesystem.FS.namespace != nil {
|
||||||
|
return input.filesystem.FS.namespace.token
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,186 @@ func testGroupedLoad[T any](request loadRequest[T]) (*testResult[T], error) {
|
|||||||
return testLoaded(loaded), err
|
return testLoaded(loaded), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnderlayGroupSchedulesInterleavedImportBranches(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
zRoot := writeImportFile(t, directory, "z-root.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: d-parent.yml
|
||||||
|
items: [{id: z}]
|
||||||
|
`)
|
||||||
|
dParent := writeImportFile(t, directory, "d-parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: a-dependency.yml
|
||||||
|
items: [{id: d}]
|
||||||
|
`)
|
||||||
|
aDependency := writeImportFile(t, directory, "a-dependency.yml", "items: [{id: a}]\n")
|
||||||
|
bRoot := writeImportFile(t, directory, "b-root.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: c-helper.yml
|
||||||
|
items: [{id: b}]
|
||||||
|
`)
|
||||||
|
cHelper := writeImportFile(t, directory, "c-helper.yml", "items: [{id: c}]\n")
|
||||||
|
|
||||||
|
request := loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: NoSource()},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{
|
||||||
|
{source: NoSource()},
|
||||||
|
{source: File(zRoot), imports: true},
|
||||||
|
{source: File(bRoot), imports: true},
|
||||||
|
}}},
|
||||||
|
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
|
||||||
|
}
|
||||||
|
result, err := testGroupedLoad(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grouped load: %v", err)
|
||||||
|
}
|
||||||
|
items := result.Config["items"].([]any)
|
||||||
|
wantIDs := []string{"a", "c", "b", "d", "z"}
|
||||||
|
if len(items) != len(wantIDs) {
|
||||||
|
t.Fatalf("scheduled items = %#v", items)
|
||||||
|
}
|
||||||
|
for index, want := range wantIDs {
|
||||||
|
if got := items[index].(map[string]any)["id"]; got != want {
|
||||||
|
t.Errorf("scheduled item %d ID = %#v, want %q", index, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantPaths := []string{zRoot, dParent, aDependency, bRoot, cHelper}
|
||||||
|
for index, want := range wantPaths {
|
||||||
|
if got := result.Sources()[index].Path; got != canonicalTestPath(t, want) {
|
||||||
|
t.Errorf("acquired source %d path = %q, want %q", index, got, canonicalTestPath(t, want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := result.Sources(); len(got) != len(wantPaths) {
|
||||||
|
t.Fatalf("NoSource added a source record: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testing.T) {
|
||||||
|
firstNamespace := NewFilesystem("same-label", fstest.MapFS{
|
||||||
|
"root.yml": &fstest.MapFile{Data: []byte("items: [{id: first}]\n")},
|
||||||
|
"later.yml": &fstest.MapFile{Data: []byte("items: [{id: copied}]\n")},
|
||||||
|
})
|
||||||
|
secondNamespace := NewFilesystem("same-label", fstest.MapFS{
|
||||||
|
"root.yml": &fstest.MapFile{Data: []byte("items: [{id: second}]\n")},
|
||||||
|
})
|
||||||
|
request := loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: NoSource()},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{
|
||||||
|
{source: firstNamespace.File("root.yml")},
|
||||||
|
{source: secondNamespace.File("root.yml")},
|
||||||
|
{source: firstNamespace.File("later.yml")},
|
||||||
|
}}},
|
||||||
|
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
|
||||||
|
}
|
||||||
|
result, err := testGroupedLoad(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grouped load with native namespaces: %v", err)
|
||||||
|
}
|
||||||
|
items := result.Config["items"].([]any)
|
||||||
|
wantIDs := []string{"copied", "first", "second"}
|
||||||
|
if len(items) != len(wantIDs) {
|
||||||
|
t.Fatalf("scheduled native items = %#v", items)
|
||||||
|
}
|
||||||
|
for index, want := range wantIDs {
|
||||||
|
if got := items[index].(map[string]any)["id"]; got != want {
|
||||||
|
t.Errorf("scheduled native item %d ID = %#v, want %q", index, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != len(wantIDs) {
|
||||||
|
t.Fatalf("source records = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
zItem := writeImportFile(t, directory, "z-item.yml", "id: duplicate\n")
|
||||||
|
aItem := writeImportFile(t, directory, "a-item.yml", "id: duplicate\n")
|
||||||
|
request := loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: NoSource()},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{
|
||||||
|
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||||
|
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||||
|
}}},
|
||||||
|
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
|
||||||
|
}
|
||||||
|
_, err := testGroupedLoad(request)
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("expected item conflict: %v", err)
|
||||||
|
}
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items" {
|
||||||
|
t.Fatalf("group item conflict = %s %s, want compose.conflict /items", diagnostic.Code, diagnostic.Path.String())
|
||||||
|
}
|
||||||
|
files := make(map[string]string)
|
||||||
|
for _, source := range loadError.Sources() {
|
||||||
|
files[source.ID] = source.Path
|
||||||
|
}
|
||||||
|
locations := append([]Location(nil), diagnostic.Related...)
|
||||||
|
if diagnostic.Primary != nil {
|
||||||
|
locations = append(locations, *diagnostic.Primary)
|
||||||
|
}
|
||||||
|
wantLocations := map[string]Location{
|
||||||
|
canonicalTestPath(t, zItem): {Line: 1, Column: 5},
|
||||||
|
canonicalTestPath(t, aItem): {Line: 1, Column: 5},
|
||||||
|
}
|
||||||
|
gotPaths := make(map[string]bool, len(locations))
|
||||||
|
for _, location := range locations {
|
||||||
|
want := wantLocations[files[location.SourceID]]
|
||||||
|
gotPaths[files[location.SourceID]] = location.Line == want.Line && location.Column == want.Column
|
||||||
|
}
|
||||||
|
if len(locations) != 2 || !gotPaths[canonicalTestPath(t, zItem)] || !gotPaths[canonicalTestPath(t, aItem)] {
|
||||||
|
t.Fatalf("group item conflict sites = %#v; source paths = %#v", locations, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
zBad := writeImportFile(t, directory, "z-bad.yml", "configkit: {imports: [{path: helper.yml}]}\nid: duplicate\n")
|
||||||
|
helper := writeImportFile(t, directory, "helper.yml", "id: duplicate\n")
|
||||||
|
aGood := writeImportFile(t, directory, "a-good.yml", "id: good\n")
|
||||||
|
request := loadRequest[map[string]any]{
|
||||||
|
main: loadInput{source: NoSource()},
|
||||||
|
underlays: []loadGroup{{inputs: []loadInput{
|
||||||
|
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||||
|
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||||
|
}}},
|
||||||
|
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
|
||||||
|
}
|
||||||
|
_, err := testGroupedLoad(request)
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("expected item helper conflict: %v", err)
|
||||||
|
}
|
||||||
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
|
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/items/1/id" {
|
||||||
|
t.Fatalf("group item helper conflict = %s %s, want compose.conflict /items/1/id", diagnostic.Code, diagnostic.Path.String())
|
||||||
|
}
|
||||||
|
files := make(map[string]string)
|
||||||
|
for _, source := range loadError.Sources() {
|
||||||
|
files[source.ID] = source.Path
|
||||||
|
}
|
||||||
|
if len(files) != 3 {
|
||||||
|
t.Fatalf("group acquisition stopped before all sources were read: %#v", files)
|
||||||
|
}
|
||||||
|
locations := append([]Location(nil), diagnostic.Related...)
|
||||||
|
if diagnostic.Primary != nil {
|
||||||
|
locations = append(locations, *diagnostic.Primary)
|
||||||
|
}
|
||||||
|
wantLocations := map[string]Location{
|
||||||
|
canonicalTestPath(t, zBad): {Line: 2, Column: 5},
|
||||||
|
canonicalTestPath(t, helper): {Line: 1, Column: 5},
|
||||||
|
}
|
||||||
|
gotPaths := make(map[string]bool, len(locations))
|
||||||
|
for _, location := range locations {
|
||||||
|
want := wantLocations[files[location.SourceID]]
|
||||||
|
gotPaths[files[location.SourceID]] = location.Line == want.Line && location.Column == want.Column
|
||||||
|
}
|
||||||
|
if len(locations) != 2 || !gotPaths[canonicalTestPath(t, zBad)] || !gotPaths[canonicalTestPath(t, helper)] {
|
||||||
|
t.Fatalf("group item helper conflict sites = %#v; source paths = %#v", locations, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle(t *testing.T) {
|
func TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle(t *testing.T) {
|
||||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: imported.yml}]}\nversion: v1\nport: 5\nsettings: {shared: main}\n")},
|
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: imported.yml}]}\nversion: v1\nport: 5\nsettings: {shared: main}\n")},
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) {
|
|||||||
t.Errorf("%q diagnostics = %#v", name, diagnostics)
|
t.Errorf("%q diagnostics = %#v", name, diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
missingImport := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: host-only.yml}]}\n")}}
|
missingImport := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: host-only.yml, required: true}]}\n")}}
|
||||||
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", missingImport), "main.yml"), Options[importTestConfig]{Imports: true})
|
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", missingImport), "main.yml"), Options[importTestConfig]{Imports: true})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("missing virtual import fell back to the host filesystem")
|
t.Fatal("missing virtual import fell back to the host filesystem")
|
||||||
@@ -170,9 +170,7 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
|||||||
imports:
|
imports:
|
||||||
- path: ../shared.yml
|
- path: ../shared.yml
|
||||||
- path: absent.yml
|
- path: absent.yml
|
||||||
optional: true
|
|
||||||
- glob: absent/*.yml
|
- glob: absent/*.yml
|
||||||
optional: true
|
|
||||||
`)},
|
`)},
|
||||||
"config/shared.yml": &fstest.MapFile{Data: []byte("items: {shared: retained}\n")},
|
"config/shared.yml": &fstest.MapFile{Data: []byte("items: {shared: retained}\n")},
|
||||||
"outside.yml": &fstest.MapFile{Data: []byte("items: {outside: blocked}\n")},
|
"outside.yml": &fstest.MapFile{Data: []byte("items: {outside: blocked}\n")},
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
if len(supplied) == 1 {
|
if len(supplied) == 1 {
|
||||||
options = supplied[0]
|
options = supplied[0]
|
||||||
}
|
}
|
||||||
|
options.importDefaults = snapshotImportDefaults(options.importDefaults)
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
|
if !options.importDefaultsValidated {
|
||||||
|
diagnostics = validateImportDefaults(options.importDefaults)
|
||||||
|
}
|
||||||
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,11 +192,105 @@ type SourceInfo struct {
|
|||||||
UnderlayIndex int
|
UnderlayIndex int
|
||||||
ImportedFrom *Location
|
ImportedFrom *Location
|
||||||
VersionCheck *SourceVersionCheck
|
VersionCheck *SourceVersionCheck
|
||||||
|
importSelections []importSelectionEvidence
|
||||||
namespace *namespaceToken
|
namespace *namespaceToken
|
||||||
placementMode placementMode
|
placementMode placementMode
|
||||||
destinationUnresolved bool
|
destinationUnresolved bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importSelectionEvidence records one completed optional declaration whose
|
||||||
|
// selected candidates produced no admitted source.
|
||||||
|
type importSelectionEvidence struct {
|
||||||
|
path Path
|
||||||
|
location Location
|
||||||
|
selector importSelector
|
||||||
|
outcome importSelectionOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportSelectionForFacade is a root-converted source-selection transport
|
||||||
|
// record. Its paths and enum spellings are converted by the facade.
|
||||||
|
type ImportSelectionForFacade struct {
|
||||||
|
Path Path // source-local import declaration path
|
||||||
|
Location Location // authored location and declaring source ID
|
||||||
|
Selector string // path, dir, or glob
|
||||||
|
Outcome string // none for a completed empty optional selection
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoImportSelectionsForFacade returns detached selection records for
|
||||||
|
// explicit root-owned conversion.
|
||||||
|
func SourceInfoImportSelectionsForFacade(source SourceInfo) []ImportSelectionForFacade {
|
||||||
|
if source.importSelections == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]ImportSelectionForFacade, len(source.importSelections))
|
||||||
|
for index, selection := range source.importSelections {
|
||||||
|
result[index] = ImportSelectionForFacade{
|
||||||
|
Path: selection.path.clone(), Location: selection.location,
|
||||||
|
Selector: importSelectorString(selection.selector),
|
||||||
|
Outcome: importSelectionOutcomeString(selection.outcome),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoWithImportSelections copies converted root-owned records into
|
||||||
|
// private engine source evidence.
|
||||||
|
func SourceInfoWithImportSelections(source SourceInfo, selections []ImportSelectionForFacade) SourceInfo {
|
||||||
|
if selections == nil {
|
||||||
|
source.importSelections = nil
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
source.importSelections = make([]importSelectionEvidence, len(selections))
|
||||||
|
for index, selection := range selections {
|
||||||
|
source.importSelections[index] = importSelectionEvidence{
|
||||||
|
path: selection.Path.clone(), location: selection.Location,
|
||||||
|
selector: importSelectorFromString(selection.Selector),
|
||||||
|
outcome: importSelectionOutcomeFromString(selection.Outcome),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
func importSelectorString(value importSelector) string {
|
||||||
|
switch value {
|
||||||
|
case importSelectorPath:
|
||||||
|
return "path"
|
||||||
|
case importSelectorDirectory:
|
||||||
|
return "dir"
|
||||||
|
case importSelectorGlob:
|
||||||
|
return "glob"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importSelectorFromString(value string) importSelector {
|
||||||
|
switch value {
|
||||||
|
case "path":
|
||||||
|
return importSelectorPath
|
||||||
|
case "dir":
|
||||||
|
return importSelectorDirectory
|
||||||
|
case "glob":
|
||||||
|
return importSelectorGlob
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importSelectionOutcomeString(value importSelectionOutcome) string {
|
||||||
|
if value == importSelectionNone {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func importSelectionOutcomeFromString(value string) importSelectionOutcome {
|
||||||
|
if value == "none" {
|
||||||
|
return importSelectionNone
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
||||||
// root-owned numeric representation.
|
// root-owned numeric representation.
|
||||||
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||||
@@ -293,6 +387,14 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
|
|||||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||||
source.Destination = source.Destination.clone()
|
source.Destination = source.Destination.clone()
|
||||||
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||||
|
if source.importSelections != nil {
|
||||||
|
selections := make([]importSelectionEvidence, len(source.importSelections))
|
||||||
|
for index, selection := range source.importSelections {
|
||||||
|
selection.path = selection.path.clone()
|
||||||
|
selections[index] = selection
|
||||||
|
}
|
||||||
|
source.importSelections = selections
|
||||||
|
}
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
|||||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\nitems: [{id: authored}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\nitems: [{id: authored}]\n")
|
||||||
writeImportFile(t, directory, "fragment.yml", "items: [{id: imported}]\n")
|
writeImportFile(t, directory, "fragment.yml", "items: [{id: imported}]\n")
|
||||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
|
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
|
||||||
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[1].ID != "imported" {
|
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[0].ID != "imported" || result.Config.Items[1].ID != "authored" {
|
||||||
t.Fatalf("registered imported list = %#v, %v", result, err)
|
t.Fatalf("registered imported list = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,9 +23,16 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
|
|||||||
filesystem = context.FS.namespace.filesystem
|
filesystem = context.FS.namespace.filesystem
|
||||||
rootPath, basePath = context.Root, context.BaseDir
|
rootPath, basePath = context.Root, context.BaseDir
|
||||||
} else if source.kind == sourceFile {
|
} else if source.kind == sourceFile {
|
||||||
filePath := sources[0].Path
|
filePath := source.path
|
||||||
if baseSource != nil {
|
if baseSource != nil {
|
||||||
filePath = baseSource.Path
|
filePath = baseSource.Path
|
||||||
|
} else {
|
||||||
|
canonical, err := canonicalExistingPath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "selected source could not be canonicalized", nil)
|
||||||
|
return nil, &value
|
||||||
|
}
|
||||||
|
filePath = canonical
|
||||||
}
|
}
|
||||||
basePath = filepath.Dir(filePath)
|
basePath = filepath.Dir(filePath)
|
||||||
rootPath = basePath
|
rootPath = basePath
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type sourceVersionState struct {
|
|||||||
localRule *VersionRule
|
localRule *VersionRule
|
||||||
effectiveRule *VersionRule
|
effectiveRule *VersionRule
|
||||||
mode sourceVersionMode
|
mode sourceVersionMode
|
||||||
|
importDefaults importDefaults
|
||||||
mainProcessing *sourceVersionProcessingResult
|
mainProcessing *sourceVersionProcessingResult
|
||||||
evidence []*sourceVersionEvidence
|
evidence []*sourceVersionEvidence
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
@@ -58,15 +59,18 @@ func sourceInfosWithVersionEvidence(sources []SourceInfo, evidence []*sourceVers
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
sensitivity := newDiffSensitivity(secrets, nil)
|
sensitivity := newDiffSensitivity(secrets, nil)
|
||||||
|
sourceIndexes := make(map[string]int, len(result))
|
||||||
|
for index := range result {
|
||||||
|
if _, found := sourceIndexes[result[index].ID]; !found {
|
||||||
|
sourceIndexes[result[index].ID] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, item := range evidence {
|
for _, item := range evidence {
|
||||||
if item == nil || item.sourceID == "" {
|
if item == nil || item.sourceID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for index := range result {
|
if index, found := sourceIndexes[item.sourceID]; found {
|
||||||
if result[index].ID == item.sourceID {
|
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
|
||||||
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -136,7 +140,7 @@ func (state *sourceVersionState) process(root *document.Node, role sourceVersion
|
|||||||
|
|
||||||
type sourceVersionProcessingResult struct {
|
type sourceVersionProcessingResult struct {
|
||||||
payload *document.Node
|
payload *document.Node
|
||||||
imports []importDeclaration
|
imports []importDeclarationSpec
|
||||||
evidence *sourceVersionEvidence
|
evidence *sourceVersionEvidence
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -880,7 +880,6 @@ func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) {
|
|||||||
configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: missing.yml
|
- path: missing.yml
|
||||||
optional: true
|
|
||||||
`)
|
`)
|
||||||
checks := 0
|
checks := 0
|
||||||
loaded, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
|
loaded, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type Underlay struct{ Inputs []Input }
|
|||||||
type LayeredOptions[T any] struct {
|
type LayeredOptions[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
SourceVersionMode SourceVersionMode
|
SourceVersionMode SourceVersionMode
|
||||||
|
// ImportDefaults supplies one load-wide default policy for enabled main,
|
||||||
|
// underlay, and transitive declarations. Explicit declaration values win.
|
||||||
|
ImportDefaults ImportDefaults
|
||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
@@ -73,6 +76,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
|||||||
Overlay: options.Overlay,
|
Overlay: options.Overlay,
|
||||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||||
}
|
}
|
||||||
|
result = engine.LayeredOptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
|
||||||
if options.Underlays != nil {
|
if options.Underlays != nil {
|
||||||
result.Underlays = make([]engine.Underlay, len(options.Underlays))
|
result.Underlays = make([]engine.Underlay, len(options.Underlays))
|
||||||
for index, group := range options.Underlays {
|
for index, group := range options.Underlays {
|
||||||
|
|||||||
34
lifecycle.go
34
lifecycle.go
@@ -72,8 +72,31 @@ const (
|
|||||||
SourceVersionModeRequired
|
SourceVersionModeRequired
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ImportDefaults supplies load-wide defaults for enabled import declarations.
|
||||||
|
// Each field applies independently and an explicit declaration value takes
|
||||||
|
// precedence. Nil boolean fields are unspecified and leave the library defaults
|
||||||
|
// in effect: Required false, Follow true, Hidden true, and Recursive false.
|
||||||
|
// Pattern applies to directory searches; nil leaves filenames unrestricted,
|
||||||
|
// while a non-nil empty slice selects none. Defaults apply to main, underlay,
|
||||||
|
// and transitive imports, not selected overlays. Load calls snapshot the
|
||||||
|
// pointed-to booleans and Pattern slice before acquiring sources.
|
||||||
|
type ImportDefaults struct {
|
||||||
|
// Required defaults absence handling. For path, true requires every named
|
||||||
|
// file; for dir and glob, true requires at least one selected file.
|
||||||
|
Required *bool
|
||||||
|
// Follow controls discovered symbolic links for directory and glob search.
|
||||||
|
Follow *bool
|
||||||
|
// Hidden controls discovery of hidden files and directories.
|
||||||
|
Hidden *bool
|
||||||
|
// Recursive controls descendant traversal for directory searches.
|
||||||
|
Recursive *bool
|
||||||
|
// Pattern replaces the library's unrestricted filename matching for directory
|
||||||
|
// searches unless a declaration supplies its own pattern.
|
||||||
|
Pattern []string
|
||||||
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
// Enabled source-version modes interpret Definition.Version.Path in each
|
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||||
// document's local coordinates. Placement projects checks and main protection
|
// document's local coordinates. Placement projects checks and main protection
|
||||||
@@ -86,8 +109,12 @@ type Options[T any] struct {
|
|||||||
Destination Path
|
Destination Path
|
||||||
// PlacementMode defaults to mapping placement; unknown values and root item
|
// PlacementMode defaults to mapping placement; unknown values and root item
|
||||||
// placement fail before acquisition.
|
// placement fail before acquisition.
|
||||||
PlacementMode PlacementMode
|
PlacementMode PlacementMode
|
||||||
Imports bool
|
Imports bool
|
||||||
|
// ImportDefaults supplies one load-wide default policy for every enabled
|
||||||
|
// declaration, including transitive imports. Each field is independently
|
||||||
|
// overridden by an explicit declaration value.
|
||||||
|
ImportDefaults ImportDefaults
|
||||||
Overlay string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
@@ -301,6 +328,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
|||||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
}
|
}
|
||||||
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
||||||
|
result = engine.OptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
||||||
}
|
}
|
||||||
|
|||||||
49
records.go
49
records.go
@@ -210,6 +210,42 @@ type SourceInfo struct {
|
|||||||
// and failed loads, text is conservatively withheld; successful final source
|
// and failed loads, text is conservatively withheld; successful final source
|
||||||
// records reflect completed sensitivity classification.
|
// records reflect completed sensitivity classification.
|
||||||
VersionCheck *SourceVersionCheck
|
VersionCheck *SourceVersionCheck
|
||||||
|
// ImportSelections records completed optional declarations that selected no
|
||||||
|
// sources, in declaration order. Paths are local to this declaring source.
|
||||||
|
// Each record identifies the declaration mapping, selector, and empty outcome;
|
||||||
|
// it does not list candidates or explain why a path was absent. Result, error,
|
||||||
|
// and callback snapshots detach these records and their paths.
|
||||||
|
ImportSelections []ImportSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportSelector identifies the selector used by an import declaration.
|
||||||
|
type ImportSelector string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ImportSelectorPath identifies a named-file selector.
|
||||||
|
ImportSelectorPath ImportSelector = "path"
|
||||||
|
// ImportSelectorDirectory identifies a literal directory search.
|
||||||
|
ImportSelectorDirectory ImportSelector = "dir"
|
||||||
|
// ImportSelectorGlob identifies a path-glob selector.
|
||||||
|
ImportSelectorGlob ImportSelector = "glob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportSelectionOutcome identifies the established result of import selection.
|
||||||
|
type ImportSelectionOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ImportSelectionNone means the optional declaration selected no sources.
|
||||||
|
ImportSelectionNone ImportSelectionOutcome = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportSelection records an optional import declaration that selected no
|
||||||
|
// sources. Path is local to the declaring source; Location identifies its
|
||||||
|
// declaration mapping and source ID.
|
||||||
|
type ImportSelection struct {
|
||||||
|
Path Path
|
||||||
|
Location Location
|
||||||
|
Selector ImportSelector
|
||||||
|
Outcome ImportSelectionOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceVersionCheck describes a source-local version declaration observed
|
// SourceVersionCheck describes a source-local version declaration observed
|
||||||
@@ -295,9 +331,22 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
|
|||||||
source.Destination = source.Destination.clone()
|
source.Destination = source.Destination.clone()
|
||||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||||
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||||
|
source.ImportSelections = cloneImportSelections(source.ImportSelections)
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneImportSelections(selections []ImportSelection) []ImportSelection {
|
||||||
|
if selections == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]ImportSelection, len(selections))
|
||||||
|
for index, selection := range selections {
|
||||||
|
selection.Path = selection.Path.clone()
|
||||||
|
cloned[index] = selection
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -71,3 +71,34 @@ func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
|
|||||||
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportSelectionConversionsAndCopies(t *testing.T) {
|
||||||
|
declaration, err := ParsePath("/configkit/imports/2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := SourceInfo{ID: "source-4", ImportSelections: []ImportSelection{{
|
||||||
|
Path: declaration, Location: Location{SourceID: "source-4", Line: 7, Column: 5},
|
||||||
|
Selector: ImportSelectorGlob, Outcome: ImportSelectionNone,
|
||||||
|
}}}
|
||||||
|
internal := toEngineSourceInfo(source)
|
||||||
|
transport := engine.SourceInfoImportSelectionsForFacade(internal)
|
||||||
|
if len(transport) != 1 || transport[0].Path.String() != declaration.String() || transport[0].Location.SourceID != source.ID || transport[0].Selector != "glob" || transport[0].Outcome != "none" {
|
||||||
|
t.Fatalf("engine selection evidence = %#v", transport)
|
||||||
|
}
|
||||||
|
source.ImportSelections[0].Path = Keys("changed")
|
||||||
|
converted := fromEngineSourceInfo(internal)
|
||||||
|
if len(converted.ImportSelections) != 1 || converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Location.Line != 7 || converted.ImportSelections[0].Selector != ImportSelectorGlob || converted.ImportSelections[0].Outcome != ImportSelectionNone {
|
||||||
|
t.Fatalf("converted selection evidence = %#v", converted.ImportSelections)
|
||||||
|
}
|
||||||
|
copy := cloneSourceInfo(converted)
|
||||||
|
copy.ImportSelections[0].Path = Keys("copy")
|
||||||
|
copy.ImportSelections[0].Selector = ImportSelectorPath
|
||||||
|
if converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Selector != ImportSelectorGlob {
|
||||||
|
t.Fatalf("source selection evidence changed through copy: %#v", converted.ImportSelections)
|
||||||
|
}
|
||||||
|
empty := cloneSourceInfo(SourceInfo{ImportSelections: make([]ImportSelection, 0)})
|
||||||
|
if empty.ImportSelections == nil {
|
||||||
|
t.Fatal("cloning collapsed a non-nil empty selection slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user