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
|
||||
source groups with an independently selected main input. Sources can be host
|
||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||
Enabled path and glob imports compose strictly within their group; precedence
|
||||
Enabled explicit imports compose strictly within their group; precedence
|
||||
between groups combines mappings recursively. Both entry points return a complete,
|
||||
validated configuration with detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
@@ -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),
|
||||
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
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
|
||||
@@ -43,12 +43,80 @@ func toEngineLocations(values []Location) []engine.Location {
|
||||
return result
|
||||
}
|
||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ImportSelection, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = ImportSelection{
|
||||
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.ImportSelectionForFacade, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = engine.ImportSelectionForFacade{
|
||||
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
|
||||
Selector: string(value.Selector), Outcome: string(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||
return engine.ImportDefaults{
|
||||
Required: cloneImportDefaultBool(value.Required),
|
||||
Follow: cloneImportDefaultBool(value.Follow),
|
||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneImportDefaultBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneImportDefaultStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
||||
|
||||
Status: accepted
|
||||
Status: Partially superseded by
|
||||
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
|
||||
ordering. The remaining ownership and safe explanation decisions remain accepted.
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed draft for review. Completed planning and remediation records are
|
||||
Proposed draft for review; its whole-sequence precedence replacement rule is
|
||||
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
|
||||
retains its proposed status. Completed planning and remediation records are
|
||||
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
||||
owns current behavior. The completed implementation plan is preserved in Git
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
sequence combination and the addition of identified-sequence sibling scopes,
|
||||
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
|
||||
ordering. Existing mapping-sibling scopes and the remaining ownership and
|
||||
inspection decisions remain accepted.
|
||||
|
||||
The [configuration contract](../config.md#sibling-inheritance) owns implemented
|
||||
behavior. The completed implementation plan is preserved in Git
|
||||
history at `83c59ea`.
|
||||
|
||||
## Date
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The [configuration contract](../config.md#strict-document-composition)
|
||||
defines current behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
identity ownership and sequence combination. Explicit source placement remains
|
||||
accepted.
|
||||
|
||||
The [configuration contract](../config.md#strict-document-composition)
|
||||
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
shows the maintained example. The completed roadmap, implementation plan, and
|
||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
may register strict sequence composition paths through
|
||||
`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
|
||||
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
|
||||
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||
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.
|
||||
|
||||
A mapping assembled from the selected file and imports has one composite base origin
|
||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||
has an overlay-layer composite origin with the lower contributing locations followed
|
||||
by the overlay location; an empty mapping is still an explicit container even though
|
||||
it contributes no children. Resolution origins are synthetic: they have layer
|
||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||
composite origin with the lower contributing locations followed by the overlay
|
||||
location; an empty mapping is still an explicit container even though it contributes
|
||||
no children. Resolution origins are synthetic: they have layer
|
||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||
selected overlay, then synthetic CLI records in supplied order.
|
||||
removed. Source records and IDs retain [acquisition order](#explicit-imports),
|
||||
including the underlay ordering described in [layered loading](#layered-loading).
|
||||
|
||||
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||
lists concrete transfers from contributor paths through destinations, with
|
||||
@@ -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
|
||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||
resolution operations. Structural inheritance events appear after source replay
|
||||
application-default or Go-zero baseline, followed by relevant underlay and main-tier
|
||||
contributions in precedence order, the selected overlay, supplied CLI records,
|
||||
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 equal-valued child replacements. Overlapping parent/child resolution records
|
||||
are rendered
|
||||
@@ -812,11 +822,11 @@ completed configuration.
|
||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||
Shared mapping containers retain all contributing value locations as their composite
|
||||
base origin. A conflict reports the first claim as its primary location and every
|
||||
remaining claim as related locations, in deterministic source and declaration
|
||||
traversal order. Traversal order never authorizes replacement. Composition clones
|
||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||
input document.
|
||||
base origin. A conflict reports the first claim in
|
||||
[scoped assembly order](#explicit-imports) as its primary location and every
|
||||
remaining claim as related locations in the same order. Assembly order never
|
||||
authorizes replacement. Composition clones the resulting tree and its ordered
|
||||
claim records rather than mutating or aliasing an input document.
|
||||
|
||||
## Selected overlays
|
||||
|
||||
@@ -907,14 +917,18 @@ zero.
|
||||
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||
string `path` or `glob`, an optional boolean `optional`, and an optional
|
||||
`destination` sequence of strings. Every destination string is one literal
|
||||
mapping key and follows the input-destination validation rules above; quote
|
||||
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
|
||||
and non-string members are `import.invalid`, as are all other unsupported shapes
|
||||
and fields. A `configkit` key below the root is invalid. With imports disabled,
|
||||
any occurrence remains `import.disabled`.
|
||||
application decoding. Each sequence item is a mapping with exactly one `path`,
|
||||
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
|
||||
`glob` accepts one path pattern string. The optional `required` boolean controls
|
||||
whether an empty selection or a missing named path is an error. Its default is
|
||||
false, so missing imports are absent unless `required: true` is specified. The
|
||||
removed `optional` field is invalid. `destination` is an optional sequence of
|
||||
strings. Every destination string is one literal mapping key and follows the
|
||||
input-destination validation rules above; quote numeric-looking keys so YAML
|
||||
reads them as strings. Null, scalar destinations, and non-string members are
|
||||
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
|
||||
key below the root is invalid. With imports disabled, any occurrence remains
|
||||
`import.disabled`.
|
||||
|
||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||
@@ -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.
|
||||
|
||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||
and files are traversed depth-first in declaration order: a file contributes its own
|
||||
body before its imports. Order stabilizes source records and diagnostics but never
|
||||
permits replacement. Required missing paths fail; `optional: true` suppresses only
|
||||
target nonexistence, not invalid paths, containment failures, directories,
|
||||
permissions, parse errors, or composition errors.
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory.
|
||||
An empty `path: []` selects nothing; it succeeds even when `required: true` because
|
||||
there are no named targets to be missing. A nonempty required path list verifies
|
||||
every named target, including targets later excluded from selection.
|
||||
|
||||
Directory declarations search the listed literal directory roots. Missing roots
|
||||
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
|
||||
accepts one basename pattern or a list, matching filenames only. Omission selects
|
||||
all filenames; an explicit empty list selects none. `exclude` accepts one basename
|
||||
pattern or a list and removes matching final candidates. Patterns use Go's
|
||||
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
|
||||
`depth` is valid only when recursion is effective, with direct files at depth 1 and
|
||||
depth 0 or omission meaning unlimited traversal subject to library budgets.
|
||||
`follow` controls discovered symlink entries and `hidden` controls discovered
|
||||
names beginning with a dot. Both default to true. Explicit roots retain their
|
||||
normal access behavior.
|
||||
|
||||
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
|
||||
Backslashes escape pattern characters rather than acting as directory separators.
|
||||
ConfigKit validates the complete pattern before filesystem traversal, walks only
|
||||
directories selected by preceding segments, and reads wildcard directories in
|
||||
bounded batches. Every examined entry consumes the separate visit budget, including
|
||||
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
|
||||
each declaration before depth-first source traversal. A required zero-match glob is
|
||||
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
|
||||
walk failures, matched directories, invalid documents, escapes, and repeated targets
|
||||
remain errors. Final candidates are bounded by the remaining source limit before
|
||||
their contents are accumulated.
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is
|
||||
invalid. Backslashes escape pattern characters rather than acting as directory
|
||||
separators. ConfigKit validates the complete pattern before filesystem traversal,
|
||||
walks only directories selected by preceding segments, and reads wildcard
|
||||
directories in bounded batches. Every examined entry consumes the separate visit
|
||||
budget, including nonmatches. Literal components are explicit path components;
|
||||
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
|
||||
final candidate basenames.
|
||||
|
||||
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,
|
||||
including every match of a glob; placement is not inherited. Sequence-item
|
||||
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.
|
||||
At the record root, an item import must name a nonempty nested destination.
|
||||
Physical paths continue to resolve from the file that declares each import.
|
||||
An actual empty mapping contributes an empty item, while an optional unmatched
|
||||
path or glob contributes nothing.
|
||||
An actual empty mapping contributes an empty item, while an unmatched declaration
|
||||
with default or explicit `required: false` contributes nothing.
|
||||
|
||||
For host files, the configuration root defaults to the selected file's canonical
|
||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||
@@ -984,8 +1056,13 @@ ConfigKit canonicalizes symlinks,
|
||||
checks containment with relative paths, and opens the canonical root-relative target
|
||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path operations
|
||||
and their own symlink behavior; imports stay in their selected namespace and root.
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path
|
||||
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
|
||||
that capability to inspect and resolve discovered links; otherwise it follows
|
||||
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
|
||||
native filesystem to expose link inspection, and native filesystem behavior is
|
||||
not a general sandbox guarantee. Imports stay in their selected namespace and
|
||||
root.
|
||||
|
||||
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
||||
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||
|
||||
@@ -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
|
||||
execution profile concepts.
|
||||
|
||||
## Import defaults and selection inspection
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||
is resolved independently: a declaration's explicit value wins, then the
|
||||
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
||||
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
||||
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||
slice selects no files. A declaration's own `pattern` replaces the application
|
||||
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||
|
||||
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||
declaring source's `ImportSelections`. These records identify the source-local
|
||||
declaration path, its authored location and selector, and the `none` outcome;
|
||||
they do not include candidate names or a guessed reason for absence. Returned
|
||||
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||
owns selector applicability and exact absence semantics.
|
||||
|
||||
## Shared source-version checks
|
||||
|
||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||
@@ -167,11 +185,26 @@ import, source-location, and ownership behavior.
|
||||
## Identified prompt sequences
|
||||
|
||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
||||
definition registration for the literal `prompts` path and `id` field. Its main
|
||||
file imports one prompt by path and others by glob, placing each source as an
|
||||
item. A nested ordinary helper import supplies an item's ID. The main tier
|
||||
strictly appends the distinct records in contribution order; its completed
|
||||
sequence replaces the lower-priority default collection as one value.
|
||||
definition registration for the literal `prompts` path and `id` field. Its
|
||||
load-wide filename defaults accept two patterns, an explicit directory
|
||||
declaration overrides those patterns, and an exclusion omits one matching
|
||||
candidate. A nested ordinary helper import supplies an item's ID. A separate
|
||||
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
|
||||
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)
|
||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
|
||||
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
@@ -148,6 +152,20 @@ git show 008d2ba:docs/roadmap/implementation.md
|
||||
git show 008d2ba:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The completed import-discovery roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `5747ec4` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 5747ec4:docs/roadmap/imports.md
|
||||
git show 5747ec4:docs/roadmap/implementation.md
|
||||
git show 5747ec4:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The subsequent import-integration audit and completed remediation record are
|
||||
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||
|
||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||
|
||||
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'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||
| 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
|
||||
composition within strict tiers, and immediate-parent-relative import
|
||||
placement within that assembly. Higher-priority sequences replace lower
|
||||
sequences as whole values. The engine also owns destination-local sibling
|
||||
inheritance before typed decoding, plus shared source-version checking with
|
||||
sanitized declaration evidence.
|
||||
sequences as whole values. The engine also owns dependency-ready,
|
||||
destination-local sibling inheritance before typed decoding, plus shared
|
||||
source-version checking with sanitized declaration evidence.
|
||||
Explicit import discovery supports declared paths, directory searches, and
|
||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
||||
root facade without changing acquisition order. Import and inheritance
|
||||
resolution share dependency-ready scheduling within their composition scopes.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
@@ -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 source-version decision.
|
||||
[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
|
||||
|
||||
**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)
|
||||
|
||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
||||
Constraints must reflect deliberately selected requirements and decisions.
|
||||
Do not promote incidental implementation choices into architectural invariants.
|
||||
|
||||
The 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
|
||||
|
||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||
@@ -91,6 +97,48 @@ boundaries, and keep simple consumers independent of optional composition
|
||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||
implemented surface against representative simple and complex shapes.
|
||||
|
||||
## Simplicity, Defaults, And Explicit Controls
|
||||
|
||||
These principles apply throughout ConfigKit: to configuration syntax, the public
|
||||
Go API, defaults, and feature design. Evaluate their effects on both configuration
|
||||
authors and consuming applications.
|
||||
|
||||
**Keep straightforward workflows straightforward.** Common configuration
|
||||
workflows should require little configuration text and minimal API setup.
|
||||
Support more demanding workflows through explicit, composable options, without
|
||||
requiring simple consumers to understand or configure those options.
|
||||
|
||||
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
|
||||
defaults that minimize required setup and accommodate valid configurations
|
||||
without unnecessary restrictions. Provide selectable options for workflows that
|
||||
need additional capabilities, narrower behavior, or stronger requirements.
|
||||
|
||||
Measure simplicity by what consumers must understand and maintain, including
|
||||
configuration text and application-side plumbing. Concise syntax is useful, but
|
||||
hidden inference and interacting meanings can make a shorter declaration harder
|
||||
to understand. Prefer explicitness where it makes behavior more predictable.
|
||||
A terse configuration format that requires substantial application-side
|
||||
infrastructure does not satisfy the simplicity principle.
|
||||
|
||||
Define what permissiveness means for each behavior. Allowing an optional
|
||||
contribution to be absent or accepting valid inputs without an unnecessary
|
||||
filename restriction can reduce required setup. Invalid declarations, ambiguous
|
||||
composition, and unreadable selected sources must still produce meaningful
|
||||
errors under the applicable contracts. Permissiveness does not authorize silent
|
||||
recovery from invalid supplied configuration or weaken configuration integrity.
|
||||
|
||||
Options should express coherent choices with understandable purposes and fit
|
||||
ConfigKit's shared configuration model. Support powerful controls when they
|
||||
serve concrete needs; these principles do not require a switch for every
|
||||
implementation behavior or conceivable consumer preference.
|
||||
|
||||
Defaults require judgment when simplicity and permissiveness pull in different
|
||||
directions. Broader discovery can accommodate more layouts while increasing the
|
||||
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
|
||||
not mechanically select the broadest behavior for every option. Changes to
|
||||
existing defaults remain subject to compatibility review, and proposed behavior
|
||||
remains in the roadmap until implemented.
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||
@@ -111,6 +159,14 @@ the import extension.
|
||||
Library code must not depend on consuming applications or initialize their
|
||||
runtime resources. It must not perform implicit network access, discover
|
||||
application-specific paths, or change process-global state during ordinary `Load`.
|
||||
Import discovery is available only through declarations in selected sources:
|
||||
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||
ConfigKit does not search conventional application paths or discover a main
|
||||
configuration file implicitly. Imports and sibling inheritance share
|
||||
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
|
||||
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
||||
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||
must never run implicitly as part of configuration loading.
|
||||
@@ -133,6 +189,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
||||
the precise completion and ownership behavior. The proposed decision record is
|
||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||
|
||||
### Accepted Processing Order
|
||||
|
||||
Complete source composition and precedence, including selected overlays, before
|
||||
sibling inheritance. Then apply typed defaults and decode the configuration.
|
||||
Apply explicit CLI overrides to that configuration and check their types before
|
||||
running application resolution. Effective secret lookup and final validation
|
||||
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
|
||||
records this ordering.
|
||||
|
||||
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||
must not introduce structural directives; earlier source, inheritance, and
|
||||
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||
Semantic validation assesses the final configuration after overrides and resolution.
|
||||
|
||||
Applications own flag parsing and domain resolution. ConfigKit owns override
|
||||
application, validation, and source attribution. Resolution receives CLI values
|
||||
with their explicit-presence and source information. Absence-based defaults
|
||||
preserve explicit values; recorded normalization and derivation can still change
|
||||
them. Typed defaults and application resolution retain their own assignment
|
||||
semantics, independently of collection operations during source assembly.
|
||||
|
||||
## Public API And Dependency Direction
|
||||
|
||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||
@@ -161,41 +240,44 @@ framework on this library.
|
||||
|
||||
## Configuration Integrity
|
||||
|
||||
Within a configuration tier, independent contributions use strict composition:
|
||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
||||
are conflicts, including equal values, except that sequences at explicitly
|
||||
registered paths append records with distinct present string identities. Between
|
||||
precedence tiers, intentional overlay combines mappings recursively, retains
|
||||
omitted lower-priority children,
|
||||
and replaces other present values according to the existing overlay rules.
|
||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
||||
reporting, not permission to override. The distinction and its rationale are
|
||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||
|
||||
Sequence-item placement and sequence registration are explicit source and
|
||||
definition controls; registration does not change precedence replacement. The
|
||||
[configuration contract](../config.md#strict-document-composition) defines the
|
||||
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
|
||||
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
shows the implemented subset. This feature does not authorize arbitrary
|
||||
configurable merge strategies.
|
||||
Within a configuration tier, independent contributions use strict composition.
|
||||
Shared mappings combine recursively. Sequences with an applicable identity
|
||||
combine distinct records and reject duplicate identities, including equal
|
||||
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||
values. Conflicting identity or operation declarations among peers also fail.
|
||||
Traversal order establishes stable reporting and record order; it does not grant
|
||||
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||
records the distinction between strict composition and intentional overrides.
|
||||
|
||||
The main document and its imports form a strictly composed tier. Explicit
|
||||
underlay groups compose strictly within each group, then combine recursively
|
||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||
determine its precedence role. Input-specific constraints, including CLI path
|
||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
||||
semantics do not require identical acquisition or lifetime behavior from every
|
||||
source kind.
|
||||
underlay groups compose strictly within each group, then combine from lowest to
|
||||
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||
other present values retain the established replacement rules. Source acquisition
|
||||
kind does not determine precedence. Input-specific constraints, including version
|
||||
ownership and path protections, remain part of the configuration contract.
|
||||
Consistent combination semantics do not require identical acquisition or lifetime
|
||||
behavior from every source kind.
|
||||
|
||||
After source precedence is complete, built-in sibling `extends` relationships
|
||||
resolve structurally in the same parser-independent representation, using the
|
||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
||||
consumer registration mechanism. Enclosing relationships precede nested
|
||||
destination-local resolution; typed defaults, application resolution, and
|
||||
secret lookup retain their existing lifecycle roles. The [configuration
|
||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
||||
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||
parser-independent document representation. Preserve existing mapping-sibling
|
||||
scopes and allow an identified sequence to provide a sibling scope for its
|
||||
records. A record may inherit from one parent in that sequence, selected by
|
||||
complete identity. The child supplies and keeps its own identity while inheriting
|
||||
the parent's other settings. Parents remain ordinary records subject to normal
|
||||
validation.
|
||||
|
||||
Use the shared dependency ordering and preserve assembled sequence order.
|
||||
Resolve enclosing relationships before nested destination-local relationships.
|
||||
Mappings combine recursively; sequences use the inheriting collection's policy.
|
||||
Whole-record upsert replaces records during combination; explicit inheritance
|
||||
uses the records that remain. Inheritance remains built-in and creates no
|
||||
acquired source.
|
||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
|
||||
original scope and continuing ownership decisions, with sequence combination
|
||||
and identified-sequence inheritance defined by
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||
|
||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||
@@ -204,13 +286,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
||||
|
||||
User-controlled precedence is application defaults, ordered underlays, composed
|
||||
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
|
||||
Domain resolution follows these
|
||||
inputs and must remain recorded, not become a hidden override channel.
|
||||
This priority order is distinct from processing order: typed defaults are applied
|
||||
at decoding, and application resolution follows CLI input under the lifecycle
|
||||
rules above.
|
||||
|
||||
Preserve node-based composition until the effective input is ready for typed
|
||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
||||
values must not be used to infer whether input was absent. Final configuration,
|
||||
Preserve node-based composition through source assembly and inheritance until
|
||||
typed decoding. Preserve presence and source information through later CLI and
|
||||
application-resolution changes as well. Ordinary Go zero values must not be used
|
||||
to infer whether input was absent. Final configuration,
|
||||
metadata, and effective inspection must agree, including application resolution
|
||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
||||
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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"`
|
||||
Name string `yaml:"name"`
|
||||
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.
|
||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
@@ -28,16 +29,30 @@ func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig],
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||
}}}},
|
||||
Definition: configkit.Definition[PromptCatalogConfig]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
IdentityField: "id",
|
||||
}},
|
||||
Validate: validatePromptCatalog,
|
||||
ImportDefaults: configkit.ImportDefaults{
|
||||
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||
},
|
||||
Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
|
||||
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
|
||||
Imports: true, Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||
return configkit.Definition[PromptCatalogConfig]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
IdentityField: "id",
|
||||
}},
|
||||
Validate: validatePromptCatalog,
|
||||
}
|
||||
}
|
||||
|
||||
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
||||
for index, prompt := range configuration.Prompts {
|
||||
if strings.TrimSpace(prompt.Name) == "" {
|
||||
|
||||
@@ -3,6 +3,8 @@ package consumers_test
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -20,8 +22,26 @@ func Example_identifiedPromptCatalog() {
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
ids = append(ids, prompt.ID)
|
||||
}
|
||||
fmt.Println(ids, err)
|
||||
// Output: [main curated weather welcome] <nil>
|
||||
emptySearches := 0
|
||||
for _, source := range result.Sources() {
|
||||
for _, selection := range source.ImportSelections {
|
||||
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
||||
emptySearches++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println(ids, emptySearches, err)
|
||||
// Output: [weather curated welcome main] 1 <nil>
|
||||
}
|
||||
|
||||
func Example_requiredPromptCatalog() {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Prompts[0].ID, err)
|
||||
// Output: required <nil>
|
||||
}
|
||||
|
||||
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||
@@ -36,7 +56,7 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||
if len(result.Config.Prompts) != 4 {
|
||||
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 {
|
||||
if 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" {
|
||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
||||
}
|
||||
if prompt.ID == "ignored" {
|
||||
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
var itemImports, helperImports int
|
||||
for _, source := range result.Sources() {
|
||||
switch filepath.Base(source.Path) {
|
||||
case "curated.yml", "generated-weather.yml", "generated-welcome.yml":
|
||||
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||
t.Fatalf("item source placement = %#v", source)
|
||||
}
|
||||
@@ -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) {
|
||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||
var loadError *configkit.LoadError
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: prompts/curated.yml
|
||||
- dir: prompts
|
||||
exclude: [generated-ignored.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- glob: prompts/generated-*.yml
|
||||
- dir: featured
|
||||
pattern: [weather-*.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: optional
|
||||
prompts:
|
||||
- id: main
|
||||
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:
|
||||
imports:
|
||||
- path: curated-id.yml
|
||||
- path: helpers/curated-id.yml
|
||||
name: Curated summary
|
||||
messages:
|
||||
- Summarize the supplied content.
|
||||
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: ignored
|
||||
name: Excluded generated prompt
|
||||
messages: [This record is excluded.]
|
||||
@@ -2,3 +2,4 @@ id: welcome
|
||||
name: Welcome
|
||||
messages:
|
||||
- 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()
|
||||
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")},
|
||||
@@ -68,19 +68,17 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceOrder := make(map[string]int)
|
||||
sourceFiles := make(map[string]string)
|
||||
for index, source := range result.Sources() {
|
||||
sourceOrder[source.ID] = index
|
||||
for _, source := range result.Sources() {
|
||||
sourceFiles[source.ID] = source.Path
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path configkit.Path
|
||||
file, text string
|
||||
}{
|
||||
{configkit.Keys("records").Index(1).Key("text"), "helper-one.yml", "first helper\n"},
|
||||
{configkit.Keys("records").Index(2).Key("text"), "helper-two.yml", "second helper\n"},
|
||||
{configkit.Keys("records").Index(1).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||
} {
|
||||
explanation, ok := result.Explain(test.path)
|
||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||
@@ -95,20 +93,6 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
||||
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) {
|
||||
@@ -158,9 +142,9 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
||||
name, file, path string
|
||||
grouped, itemMain bool
|
||||
}{
|
||||
{"preceding authored and imported items", "main.yml", "/records/2/id", false, false},
|
||||
{"nested item", "nested.yml", "/records/1/children/1/id", false, false},
|
||||
{"preceding grouped input", "group.yml", "/records/2/id", true, false},
|
||||
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||
} {
|
||||
@@ -193,8 +177,16 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
||||
for _, source := range loadError.Sources() {
|
||||
files[source.ID] = source.Path
|
||||
}
|
||||
if diagnostic.Primary == nil || files[diagnostic.Primary.SourceID] != "bad.yml" || len(diagnostic.Related) != 1 || files[diagnostic.Related[0].SourceID] != "helper.yml" {
|
||||
t.Fatal("conflict lost physical source locations")
|
||||
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||
if diagnostic.Primary != nil {
|
||||
locations = append(locations, *diagnostic.Primary)
|
||||
}
|
||||
gotFiles := make(map[string]bool, len(locations))
|
||||
for _, location := range locations {
|
||||
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
|
||||
}
|
||||
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
|
||||
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -326,7 +318,7 @@ items:
|
||||
t.Fatalf("load item glob: %v", err)
|
||||
}
|
||||
items := result.Config["items"].([]any)
|
||||
wantIDs := []string{"authored", "imported-a", "imported-b"}
|
||||
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||
for index, item := range items {
|
||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||
@@ -344,10 +336,10 @@ items:
|
||||
}
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
for index := 1; index < len(wantIDs); index++ {
|
||||
for index := 0; index < 2; index++ {
|
||||
path := configkit.Keys("items").Index(index).Key("id")
|
||||
info, ok := metadata.Lookup(path)
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
|
||||
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
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) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
||||
@@ -46,6 +50,16 @@ func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !grouped {
|
||||
records, ok := result.Config["records"].([]any)
|
||||
if !ok || len(records) != 2 {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
firstRecord, ok := records[0].(map[string]any)
|
||||
if !ok || firstRecord["id"] != "child" {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
check := sources[len(sources)-1].VersionCheck
|
||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||
@@ -626,7 +640,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||
}}
|
||||
path := filepath.Join(t.TempDir(), "main.yml")
|
||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n"
|
||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n required: true\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
type composedDocument struct {
|
||||
root *document.Node
|
||||
acquisition []acquisitionScope
|
||||
importGraph importInclusionGraph
|
||||
claims []compositionClaim
|
||||
sequenceRanges []sequenceRange
|
||||
origins map[string]Origin
|
||||
@@ -51,6 +52,7 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||
importGraph: base.importGraph.clone(),
|
||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
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",
|
||||
body: "configkit: {imports: [{path: missing.yml, optional: true, destination: [unused]}]}\n",
|
||||
body: "configkit: {imports: [{path: missing.yml, destination: [unused]}]}\n",
|
||||
},
|
||||
{
|
||||
name: "optional unmatched glob",
|
||||
body: "configkit: {imports: [{glob: empty/*.yml, optional: true, destination: [unused]}]}\n",
|
||||
body: "configkit: {imports: [{glob: empty/*.yml, destination: [unused]}]}\n",
|
||||
setup: func(t *testing.T, directory string) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -451,7 +451,6 @@ func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: optional.yml
|
||||
optional: true
|
||||
`)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
||||
|
||||
@@ -9,27 +9,16 @@ import (
|
||||
|
||||
const directiveKey = "configkit"
|
||||
|
||||
type importDeclaration struct {
|
||||
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) {
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||
body := document.Clone(root)
|
||||
var declarations []importDeclaration
|
||||
var declarations []importDeclarationSpec
|
||||
directiveIndex := -1
|
||||
for index, entry := range body.Mapping {
|
||||
if entry.Key != directiveKey {
|
||||
continue
|
||||
}
|
||||
directiveIndex = index
|
||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
||||
parsed, diagnostics := parseImportSpecificationDirective(entry.Value, Keys(directiveKey))
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
@@ -45,101 +34,6 @@ func splitImportDirectives(root *document.Node) (*document.Node, []importDeclara
|
||||
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 {
|
||||
if node.Kind == document.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"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const globReadBatch = 128
|
||||
|
||||
type globCandidate struct {
|
||||
absolute string
|
||||
relative string
|
||||
}
|
||||
|
||||
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
|
||||
location := declaration.location
|
||||
segments, valid := validGlobPattern(declaration.glob)
|
||||
if !valid {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
|
||||
// selectGlobImportCandidates expands path components while retaining the
|
||||
// lexical spelling used for filtering and later source admission.
|
||||
func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting) (importSelection, []Diagnostic) {
|
||||
site := spec.fieldSites["glob"]
|
||||
segments := spec.globSegments
|
||||
if len(segments) == 0 {
|
||||
var valid bool
|
||||
segments, valid = validGlobPattern(spec.glob)
|
||||
if !valid {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []globCandidate{{absolute: basePath}}
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
rootPath := normalizedImportCandidatePath(access, basePath)
|
||||
candidates := []selectedImportCandidate{{path: rootPath, accessPath: basePath, site: site}}
|
||||
for index, segment := range segments {
|
||||
last := index == len(segments)-1
|
||||
literal, patterned := globLiteralSegment(segment)
|
||||
var next []globCandidate
|
||||
for _, candidate := range candidates {
|
||||
var names []string
|
||||
if patterned {
|
||||
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
|
||||
// nextByPath is the set union of candidates reached from each matching
|
||||
// parent at this segment; final exclusions below are set difference.
|
||||
nextByPath := make(map[string]selectedImportCandidate)
|
||||
for _, parent := range candidates {
|
||||
if !patterned {
|
||||
childPath := access.join(parent.path, literal)
|
||||
childAccessPath := access.join(parent.accessPath, literal)
|
||||
candidate, missing, diagnostics := explicitGlobCandidate(access, basePath, childPath, childAccessPath, site, last)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
return importSelection{}, diagnostics
|
||||
}
|
||||
names = matched
|
||||
} else {
|
||||
names = []string{literal}
|
||||
if !missing {
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, name := range names {
|
||||
absolute := s.access.join(candidate.absolute, name)
|
||||
relative := path.Join(candidate.relative, name)
|
||||
if !s.access.contains(absolute) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
||||
}
|
||||
canonical, err := s.access.canonical(absolute)
|
||||
|
||||
entries, missing, diagnostics := readGlobEntries(access, parent.accessPath, site.location, limits, accounting)
|
||||
if len(diagnostics) != 0 {
|
||||
return importSelection{}, diagnostics
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := path.Match(segment, entry.Name())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
||||
continue
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||
}
|
||||
if !s.access.contains(canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
||||
}
|
||||
if last {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
if s.accounting.sources+len(next) > s.limits.sources {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
||||
}
|
||||
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
info, err := s.access.stat(canonical)
|
||||
accessPath := access.join(parent.accessPath, entry.Name())
|
||||
lexicalPath := access.join(parent.path, entry.Name())
|
||||
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
||||
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
|
||||
}
|
||||
if info.IsDir() {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
if target.symlink && !policy.follow {
|
||||
continue
|
||||
}
|
||||
if !last && !target.dir {
|
||||
continue
|
||||
}
|
||||
physicalPath := target.identity
|
||||
// Native source identity and relative imports use the namespace
|
||||
// spelling; resolved link identities belong only to discovery.
|
||||
if access.native != nil {
|
||||
physicalPath = accessPath
|
||||
}
|
||||
if physicalPath == "" {
|
||||
physicalPath, err = access.canonical(accessPath)
|
||||
if err != nil {
|
||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
|
||||
}
|
||||
}
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
|
||||
}
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
}
|
||||
candidates = next
|
||||
candidates = candidatesFromMap(nextByPath)
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
matches := make([]string, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
matches[index] = candidate.relative
|
||||
selected := make([]selectedImportCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
missing, _, diagnostic := validateNamedImportTarget(access, candidate, false, false)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if missing || excludedBasename(path.Base(candidate.path), policy.exclude) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, candidate)
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches, nil
|
||||
if policy.required && len(selected) == 0 {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportMissing, "required import glob selected no files")}
|
||||
}
|
||||
// Source allowance is charged by source admission, after this filtered set
|
||||
// reaches the traversal; that preserves repeated-source diagnostics first.
|
||||
selection := importSelection{candidates: selected}
|
||||
if len(selected) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
||||
canonical, err := s.access.canonical(directory)
|
||||
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
||||
if !access.contains(candidatePath) || !access.contains(accessPath) {
|
||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, nil, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
return selectedImportCandidate{}, true, nil
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||
return selectedImportCandidate{}, false, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
|
||||
}
|
||||
if !s.access.contains(canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||
canonical, err := access.canonical(accessPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return selectedImportCandidate{}, true, nil
|
||||
}
|
||||
return selectedImportCandidate{}, false, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
|
||||
}
|
||||
if _, err := s.access.relative(canonical); err != nil {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||
if !access.contains(canonical) {
|
||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal resolves outside the configuration root")}
|
||||
}
|
||||
var matches []string
|
||||
var problem *Diagnostic
|
||||
err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
|
||||
s.accounting.entries++
|
||||
if s.accounting.entries > s.limits.entries {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)
|
||||
problem = &value
|
||||
return false
|
||||
if !last {
|
||||
if !target.dir {
|
||||
return selectedImportCandidate{}, true, nil
|
||||
}
|
||||
matched, matchErr := path.Match(pattern, entry.Name())
|
||||
if matchErr != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
|
||||
problem = &value
|
||||
return false
|
||||
}
|
||||
return selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, candidatePath), accessPath: canonical,
|
||||
label: relativeImportLabel(access, basePath, candidatePath), site: site,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func readGlobEntries(access *filesystemAccess, directory string, location Location, limits importLimits, accounting *importAccounting) ([]fs.DirEntry, bool, []Diagnostic) {
|
||||
target, err := access.importEntryTarget(directory, nil, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if matched {
|
||||
matches = append(matches, entry.Name())
|
||||
return nil, false, []Diagnostic{globCandidateDiagnostic(err, location, "glob directory could not be inspected")}
|
||||
}
|
||||
canonical, err := access.canonical(directory)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return true
|
||||
return nil, false, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||
}
|
||||
if !access.contains(canonical) {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||
}
|
||||
if _, err := access.relative(canonical); err != nil {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||
}
|
||||
readPath := target.identity
|
||||
if readPath == "" {
|
||||
readPath = canonical
|
||||
}
|
||||
entries := make([]fs.DirEntry, 0)
|
||||
err = visitBoundedImportDirectory(access, readPath, limits.entries, accounting, func(entry fs.DirEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})
|
||||
if problem != nil {
|
||||
return nil, []Diagnostic{*problem}
|
||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
|
||||
return nil, false, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches, nil
|
||||
sort.Slice(entries, func(left, right int) bool { return entries[left].Name() < entries[right].Name() })
|
||||
return entries, false, nil
|
||||
}
|
||||
|
||||
func globCandidateDiagnostic(err error, location Location, message string) Diagnostic {
|
||||
if errors.Is(err, errImportDirectoryLinkCycle) {
|
||||
return importDiagnostic(CodeImportCycle, "directory traversal cycle detected", &location)
|
||||
}
|
||||
if errors.Is(err, errImportPathOutsideRoot) {
|
||||
return importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)
|
||||
}
|
||||
return globFilesystemDiagnostic(err, message, location)
|
||||
}
|
||||
|
||||
func candidatesFromMap(candidates map[string]selectedImportCandidate) []selectedImportCandidate {
|
||||
paths := make([]string, 0, len(candidates))
|
||||
for candidatePath := range candidates {
|
||||
paths = append(paths, candidatePath)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
result := make([]selectedImportCandidate, 0, len(paths))
|
||||
for _, candidatePath := range paths {
|
||||
result = append(result, candidates[candidatePath])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validGlobPattern(pattern string) ([]string, bool) {
|
||||
|
||||
203
internal/engine/glob_selection_test.go
Normal file
203
internal/engine/glob_selection_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestSelectGlobFiltersOnlyWildcardDiscoveredEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
".hidden/secret.yaml": "value: hidden\n",
|
||||
"visible/item.yaml": "value: visible\n",
|
||||
"visible/source.txt": "value: source\n",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for alias, target := range map[string]string{
|
||||
"visible-alias": "visible",
|
||||
"selected.yaml": "visible/source.txt",
|
||||
"unselected.txt": "visible/item.yaml",
|
||||
".secret.yaml": "visible/source.txt",
|
||||
} {
|
||||
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(alias))); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
selection := selectGlobForTest(t, access, root, ` - glob: '*/*.yaml'
|
||||
follow: false
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible/item.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("filtered glob candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: '.hidden/*.yaml'
|
||||
follow: false
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{".hidden/secret.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("literal hidden component candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: 'visible-alias/*.yaml'
|
||||
follow: false
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"visible-alias/item.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("literal symlink component candidates = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: '*.yaml'
|
||||
follow: false
|
||||
hidden: true
|
||||
`)
|
||||
if len(selection.candidates) != 0 {
|
||||
t.Fatalf("follow:false selected a discovered file symlink: %#v", selection.candidates)
|
||||
}
|
||||
|
||||
selection = selectGlobForTest(t, access, root, ` - glob: '*.yaml'
|
||||
follow: true
|
||||
hidden: false
|
||||
`)
|
||||
if got, want := selectedPathsRelativeTo(selection, root), []string{"selected.yaml"}; !equalStrings(got, want) {
|
||||
t.Fatalf("filename aliases were not filtered by candidate spelling: %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := &observedGlobFilesystem{files: fstest.MapFS{
|
||||
"parts": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"parts/keep.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
"parts/skip-one.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
"parts/skip-two.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
"parts/skip-three.yaml": &fstest.MapFile{Data: []byte("not parsed: [\n")},
|
||||
}}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
specs, diagnostics := parseImportSpecsYAML(t, ` - glob: 'parts/*.yaml'
|
||||
exclude: 'skip-*.yaml'
|
||||
`)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
limits := defaultImportLimits()
|
||||
limits.sources = 2
|
||||
accounting := &importAccounting{sources: 1}
|
||||
selection, diagnostics := selectGlobImportCandidates(access, ".", specs[0], policy, limits, accounting)
|
||||
if len(diagnostics) != 0 || !equalStrings(selectedPaths(selection), []string{"parts/keep.yaml"}) {
|
||||
t.Fatalf("post-exclusion selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if len(filesystem.opened) != 1 || filesystem.opened[0] != "parts" {
|
||||
t.Fatalf("glob opened paths = %#v, want only the parts directory", filesystem.opened)
|
||||
}
|
||||
if accounting.sources+len(selection.candidates) > limits.sources {
|
||||
t.Fatalf("final candidate set exceeds the private source limit: accounting=%#v selection=%#v", accounting, selection.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGlobRejectsEscapingWildcardLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "file.yaml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
|
||||
_, diagnostics := selectGlobForTestWithDiagnostics(t, access, root, ` - glob: '*/file.yaml'
|
||||
follow: true
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("escaping glob diagnostics = %#v, want import.outside_root", diagnostics)
|
||||
}
|
||||
selection := selectGlobForTest(t, access, root, ` - glob: '*/file.yaml'
|
||||
follow: false
|
||||
`)
|
||||
if len(selection.candidates) != 0 {
|
||||
t.Fatalf("follow:false selected an escaping alias: %#v", selection.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGlobPreservesMatchedDirectoryValidityBeforeExclusion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := fstest.MapFS{
|
||||
"parts": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"parts/matched.yaml": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
|
||||
"parts/matched.yaml/x": &fstest.MapFile{Data: []byte("value: x\n")},
|
||||
}
|
||||
access := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||
_, diagnostics := selectGlobForTestWithDiagnostics(t, access, ".", ` - glob: 'parts/*.yaml'
|
||||
exclude: '*.yaml'
|
||||
`)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
|
||||
t.Fatalf("excluded directory diagnostics = %#v, want source.not_regular", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func selectGlobForTest(t *testing.T, access *filesystemAccess, base, declaration string) importSelection {
|
||||
t.Helper()
|
||||
selection, diagnostics := selectGlobForTestWithDiagnostics(t, access, base, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("glob selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, base, declaration string) (importSelection, []Diagnostic) {
|
||||
t.Helper()
|
||||
specs, diagnostics := parseImportSpecsYAML(t, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return selectGlobImportCandidates(access, base, specs[0], policy, defaultImportLimits(), &importAccounting{})
|
||||
}
|
||||
|
||||
type observedGlobFilesystem struct {
|
||||
files fstest.MapFS
|
||||
opened []string
|
||||
}
|
||||
|
||||
func (filesystem *observedGlobFilesystem) Open(name string) (fs.File, error) {
|
||||
filesystem.opened = append(filesystem.opened, name)
|
||||
return filesystem.files.Open(name)
|
||||
}
|
||||
|
||||
func (filesystem *observedGlobFilesystem) Stat(name string) (fs.FileInfo, error) {
|
||||
return fs.Stat(filesystem.files, name)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -106,7 +107,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, test.pattern, false)
|
||||
mainPath := writeGlobRoot(t, directory, test.pattern, test.code == CodeImportMissing)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||
})
|
||||
}
|
||||
@@ -115,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "empty/*.yml", false)
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || len(result.Sources()) != 1 {
|
||||
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
|
||||
@@ -139,7 +140,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(directory, "conf/matched.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "conf/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "conf/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourceNotRegular)
|
||||
})
|
||||
|
||||
@@ -168,6 +169,10 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Items["linked"] != "linked" {
|
||||
var loadError *LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("contained directory symlink = %#v, diagnostics %#v", result, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("contained directory symlink = %#v, %v", result, err)
|
||||
}
|
||||
})
|
||||
@@ -179,7 +184,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
})
|
||||
}
|
||||
@@ -198,7 +203,7 @@ func TestOptionalGlobDoesNotSuppressDirectoryPermissionFailure(t *testing.T) {
|
||||
_ = opened.Close()
|
||||
t.Skip("filesystem does not enforce the unreadable-directory fixture")
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "locked/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "locked/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportPermission)
|
||||
}
|
||||
|
||||
@@ -207,7 +212,7 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
||||
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
|
||||
writeImportFile(t, directory, "many/"+name, "ignored\n")
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "many/*.yml", true)
|
||||
mainPath := writeGlobRoot(t, directory, "many/*.yml", false)
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
limits.entries = 2
|
||||
@@ -263,11 +268,11 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) string {
|
||||
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
||||
t.Helper()
|
||||
optionalLine := ""
|
||||
if optional {
|
||||
optionalLine = "\n optional: true"
|
||||
requiredLine := ""
|
||||
if required {
|
||||
requiredLine = "\n required: true"
|
||||
}
|
||||
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+requiredLine+"\n")
|
||||
}
|
||||
|
||||
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 {
|
||||
canonical string
|
||||
locations []Location
|
||||
key importNodeKey
|
||||
}
|
||||
|
||||
type importAccounting struct {
|
||||
@@ -50,6 +51,7 @@ type importTraversal struct {
|
||||
limits importLimits
|
||||
rule *VersionRule
|
||||
versionState *sourceVersionState
|
||||
defaults importDefaults
|
||||
|
||||
scope acquisitionScope
|
||||
itemScope bool
|
||||
@@ -59,6 +61,7 @@ type importTraversal struct {
|
||||
active map[resourceIdentity]int
|
||||
visited map[resourceIdentity]importRoute
|
||||
stack []importRoute
|
||||
importGraph importInclusionGraph
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||
if diagnostic != nil {
|
||||
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)
|
||||
if diagnostic != nil {
|
||||
@@ -98,7 +105,7 @@ func loadImportedDocumentsTrackedWithProcessing(source Source, context *Filesyst
|
||||
if hostToken != nil {
|
||||
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) {
|
||||
@@ -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) {
|
||||
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)) {
|
||||
_ = access.close()
|
||||
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 != "" {
|
||||
mainInfo.Path = mainPath
|
||||
}
|
||||
mainKey := access.identity(mainPath)
|
||||
mainResource := access.identity(mainPath)
|
||||
mainNodeKey := importKeyFor(access, mainPath, mainPath == "")
|
||||
if mainPath == "" {
|
||||
mainKey = access.syntheticIdentity()
|
||||
mainResource = access.syntheticIdentity()
|
||||
}
|
||||
if previous, repeated := visited[mainKey]; repeated {
|
||||
if previous, repeated := visited[mainResource]; repeated {
|
||||
_ = access.close()
|
||||
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += main.bytes
|
||||
accounting.nodes += countInputNodes(main.document)
|
||||
defaults := importDefaults{}
|
||||
if versionState != nil {
|
||||
defaults = snapshotImportDefaults(versionState.importDefaults)
|
||||
}
|
||||
state := importTraversal{
|
||||
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
|
||||
scope: acquisitionScope{destination: destination.clone()},
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int), visited: visited,
|
||||
defaults: defaults,
|
||||
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int), visited: visited,
|
||||
importGraph: newImportInclusionGraph(),
|
||||
}
|
||||
if mainIsItem {
|
||||
state.itemScope = true
|
||||
@@ -176,11 +194,13 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
mainIdentity = "<selected-source>"
|
||||
}
|
||||
mainRoute := importRoute{canonical: mainIdentity}
|
||||
state.active[mainKey] = 0
|
||||
state.visited[mainKey] = mainRoute
|
||||
state.importGraph.addNode(importGraphNode{key: mainNodeKey, canonical: mainIdentity, sourceID: mainInfo.ID, role: importRoleMain})
|
||||
mainRoute.key = mainNodeKey
|
||||
state.active[mainResource] = 0
|
||||
state.visited[mainResource] = mainRoute
|
||||
state.stack = append(state.stack, mainRoute)
|
||||
var body *document.Node
|
||||
var declarations []importDeclaration
|
||||
var declarations []importDeclarationSpec
|
||||
var diagnostics []Diagnostic
|
||||
if processing != nil {
|
||||
body = processing.payload
|
||||
@@ -191,7 +211,8 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
if len(diagnostics) == 0 {
|
||||
if mainIsItem {
|
||||
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{})
|
||||
} else {
|
||||
@@ -205,12 +226,19 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
accounting.nodes += placementNodes
|
||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
||||
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||
destination: destination.clone(), processing: processing,
|
||||
})
|
||||
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()
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -222,6 +250,27 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
if closeErr != 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 {
|
||||
localScope := state.scope
|
||||
documents := make([]*document.Node, 0, len(localScope.contributions))
|
||||
@@ -245,7 +294,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
protectionRule = &scoped
|
||||
}
|
||||
}
|
||||
if diagnostic := importedVersionClaim(documents, completed, protectionRule, 0); diagnostic != nil {
|
||||
if diagnostic := importedVersionClaim(documents, localScope, completed, protectionRule); diagnostic != nil {
|
||||
if rule != nil {
|
||||
diagnostic.Path = rule.Path.clone()
|
||||
}
|
||||
@@ -267,13 +316,16 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
if composed != nil {
|
||||
synthetic := acquisitionScope{contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(mainInfo), payload: completed.root, document: placed,
|
||||
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||
destination: destination.clone(), processing: processing, nested: completed,
|
||||
}}}
|
||||
synthetic.owner = mainNodeKey
|
||||
projectNestedEvidence(composed, synthetic)
|
||||
projectAcquiredDestinations(composed, synthetic, state.sources)
|
||||
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
|
||||
composed.inputBytes = accounting.bytes
|
||||
composed.inputNodes = accounting.nodes
|
||||
composed.importGraph = state.importGraph.clone()
|
||||
}
|
||||
return composed, cloneSourceInfos(state.sources), failures
|
||||
}
|
||||
@@ -283,7 +335,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
}
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, documents...)
|
||||
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}
|
||||
}
|
||||
}
|
||||
@@ -293,10 +345,34 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
||||
composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)}
|
||||
composed.inputBytes = accounting.bytes
|
||||
composed.inputNodes = accounting.nodes
|
||||
composed.importGraph = state.importGraph.clone()
|
||||
}
|
||||
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 {
|
||||
if s.accounting.sources > s.limits.sources {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||
@@ -313,62 +389,77 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int, parentDestination Path) []Diagnostic {
|
||||
for _, declaration := range declarations {
|
||||
childDestination := appendDestination(parentDestination, declaration.destination)
|
||||
if declaration.glob != "" {
|
||||
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path) []Diagnostic {
|
||||
policies := make([]resolvedImportPolicy, len(declarations))
|
||||
for index, spec := range declarations {
|
||||
policy, diagnostics := resolveImportPolicy(spec, s.defaults)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return diagnostics
|
||||
if !policy.required && len(s.sources) == sourcesBefore {
|
||||
s.recordEmptyImportSelection(spec)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||
location := declaration.location
|
||||
if declaration.placement == placementModeSequenceItem && len(destination.segments) == 0 {
|
||||
return []Diagnostic{{
|
||||
Code: CodeImportInvalid, Severity: SeverityError,
|
||||
Path: declaration.placementPath.clone(),
|
||||
Message: "sequence-item placement requires a nonempty destination",
|
||||
Primary: cloneLocation(declaration.placementLocation),
|
||||
}}
|
||||
}
|
||||
if depth > s.limits.depth {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||
}
|
||||
if !validImportPath(declaration.path) {
|
||||
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
|
||||
func (s *importTraversal) recordEmptyImportSelection(spec importDeclarationSpec) {
|
||||
for index := range s.sources {
|
||||
if s.sources[index].ID != spec.location.SourceID {
|
||||
continue
|
||||
}
|
||||
s.sources[index].importSelections = append(s.sources[index].importSelections, importSelectionEvidence{
|
||||
path: spec.path.clone(), location: spec.location,
|
||||
selector: spec.selector, outcome: importSelectionNone,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
candidate := s.access.join(basePath, declaration.path)
|
||||
if !s.access.contains(candidate) {
|
||||
func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec importDeclarationSpec, policy resolvedImportPolicy, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||
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)}
|
||||
}
|
||||
canonical, err := s.access.canonical(candidate)
|
||||
canonical, err := s.access.canonical(candidate.accessPath)
|
||||
if err != nil {
|
||||
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
|
||||
if !policy.required && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
code := CodeImportInvalid
|
||||
@@ -383,14 +474,24 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
if !s.access.contains(canonical) {
|
||||
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)
|
||||
identity := s.access.identity(canonical)
|
||||
nodeKey := importKeyFor(s.access, canonical, false)
|
||||
importerKey := s.currentImportNode()
|
||||
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:]...)
|
||||
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||
}
|
||||
if previous, ok := s.visited[identity]; ok {
|
||||
s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||
}
|
||||
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)
|
||||
kind := SourceKindFile
|
||||
label := declaration.path
|
||||
label := candidate.label
|
||||
if label == "" {
|
||||
label = candidate.path
|
||||
}
|
||||
if s.access.native != nil {
|
||||
kind = SourceKindFilesystem
|
||||
label = s.access.label + "/" + canonical
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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 {
|
||||
return []Diagnostic{*diagnostic}
|
||||
}
|
||||
@@ -442,7 +546,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
s.accounting.nodes += nodeCount
|
||||
|
||||
var body *document.Node
|
||||
var declarations []importDeclaration
|
||||
var declarations []importDeclarationSpec
|
||||
var diagnostics []Diagnostic
|
||||
var processing *sourceVersionProcessingResult
|
||||
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
|
||||
@@ -450,7 +554,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
if s.itemScope {
|
||||
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 {
|
||||
return failures
|
||||
}
|
||||
@@ -464,15 +568,21 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
}
|
||||
placementDestination := destination
|
||||
mode := placementModeMapping
|
||||
if declaration.placement == placementModeSequenceItem {
|
||||
if policy.placement == placementModeSequenceItem {
|
||||
mode = placementModeSequenceItem
|
||||
}
|
||||
role := importRoleMapping
|
||||
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.visited[identity] = 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)
|
||||
s.stack = s.stack[:len(s.stack)-1]
|
||||
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)}
|
||||
}
|
||||
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{
|
||||
source: cloneSourceInfo(info), payload: body, document: placed,
|
||||
identity: nodeKey, role: role, scopeOwner: s.scope.owner,
|
||||
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
||||
})
|
||||
s.active[identity] = len(s.stack)
|
||||
@@ -500,6 +611,17 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
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 {
|
||||
if !s.itemScope {
|
||||
return destination.clone()
|
||||
@@ -509,10 +631,10 @@ func (s *importTraversal) sourceDestination(destination Path) Path {
|
||||
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
|
||||
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
||||
s.scope = acquisitionScope{}
|
||||
s.scope = acquisitionScope{owner: identity}
|
||||
s.itemScope = true
|
||||
if parentItemScope {
|
||||
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{
|
||||
source: cloneSourceInfo(info), payload: body, document: body,
|
||||
identity: identity, role: importRoleItem, scopeOwner: identity,
|
||||
processing: processing,
|
||||
})
|
||||
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
|
||||
itemScope := s.scope
|
||||
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||
if len(diagnostics) != 0 {
|
||||
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
|
||||
}
|
||||
documents := make([]*document.Node, 0, len(itemScope.contributions))
|
||||
for _, contribution := range itemScope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
completed, diagnostics := composeDocuments(documents...)
|
||||
if len(diagnostics) != 0 {
|
||||
return projectItemDiagnosticPaths(diagnostics, parentScope, destination)
|
||||
return diagnostics
|
||||
}
|
||||
itemScope.destination = Path{}
|
||||
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(itemScope)}
|
||||
projectNestedEvidence(completed, itemScope)
|
||||
pl := destination
|
||||
_, placed, err := placeDocumentWithMode(completed.root, pl, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
if err != nil {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &declaration.location)}
|
||||
}
|
||||
placementNodes := destinationWrapperNodeCountForMode(pl, placementModeSequenceItem)
|
||||
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &declaration.location)}
|
||||
}
|
||||
s.accounting.nodes += placementNodes
|
||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(info), payload: completed.root, document: placed,
|
||||
destination: pl.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
||||
nested: completed,
|
||||
source: cloneSourceInfo(info), payload: body,
|
||||
identity: identity, role: importRoleItem, scopeOwner: parentScope.owner,
|
||||
destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: processing,
|
||||
pendingItem: &itemScope,
|
||||
})
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
mainIndex := -1
|
||||
for index, contribution := range scope.contributions {
|
||||
if contribution.role == importRoleMain {
|
||||
mainIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||
for index, contribution := range documents {
|
||||
if index == mainIndex {
|
||||
@@ -599,10 +760,12 @@ func isCompositionDiagnostic(diagnostic Diagnostic) bool {
|
||||
// On a composition failure only the earlier contributions are needed to locate
|
||||
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
|
||||
// and access diagnostics keep their original source-local coordinates.
|
||||
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path) []Diagnostic {
|
||||
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path, beforeContribution int) []Diagnostic {
|
||||
for _, diagnostic := range diagnostics {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
limit := min(int64(maxSourceBytes), remaining)
|
||||
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)
|
||||
if failure != nil {
|
||||
if optional && failure.missingTarget {
|
||||
if !required && failure.missingTarget {
|
||||
return nil, true, nil
|
||||
}
|
||||
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) {
|
||||
directory := t.TempDir()
|
||||
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: "imports kind", body: "configkit: {imports: {}}\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: "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 value", body: "configkit: {imports: [{path: a.yml, placement: item}]}\n", code: CodeImportInvalid},
|
||||
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||
@@ -399,9 +474,8 @@ func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testi
|
||||
- glob: records/*.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
- path: absent/*.yml
|
||||
- glob: absent/*.yml
|
||||
destination: [items]
|
||||
optional: true
|
||||
placement: sequence-item
|
||||
- path: other.yml
|
||||
destination: [other]
|
||||
@@ -504,7 +578,7 @@ func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := "configkit:\n imports:\n - path: child.yml\n" + test.destination
|
||||
declarations := parseImportDeclarationsYAML(t, body)
|
||||
declarations := parseImportSpecificationsYAML(t, body)
|
||||
if len(declarations) != 1 {
|
||||
t.Fatalf("declarations = %#v", declarations)
|
||||
}
|
||||
@@ -523,7 +597,7 @@ func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
||||
|
||||
func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
declarations := parseImportDeclarationsYAML(t, `configkit:
|
||||
declarations := parseImportSpecificationsYAML(t, `configkit:
|
||||
imports:
|
||||
- path: mapping.yml
|
||||
- glob: records/*.yml
|
||||
@@ -555,7 +629,7 @@ func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n"
|
||||
parsed, diagnostics := parseImportDeclarationYAML(body)
|
||||
parsed, diagnostics := parseImportSpecificationYAML(body)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath {
|
||||
t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath)
|
||||
}
|
||||
@@ -578,12 +652,12 @@ func TestImportDestinationValidationPrecedesOptionalSuppression(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
} {
|
||||
@@ -604,7 +678,6 @@ func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: missing.yml
|
||||
optional: true
|
||||
settings: {root: retained}
|
||||
`)
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
@@ -637,12 +710,42 @@ settings: {root: retained}
|
||||
if test.setup != nil {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Run("cycle", func(t *testing.T) {
|
||||
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) {
|
||||
directory := t.TempDir()
|
||||
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) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("initial filesystem contract covers Linux and macOS")
|
||||
@@ -797,7 +1102,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||
|
||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||
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)
|
||||
|
||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||
@@ -809,7 +1114,7 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||
_ = file.Close()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
directory := t.TempDir()
|
||||
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
|
||||
}
|
||||
|
||||
func parseImportDeclarationsYAML(t *testing.T, input string) []importDeclaration {
|
||||
func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec {
|
||||
t.Helper()
|
||||
declarations, diagnostics := parseImportDeclarationYAML(input)
|
||||
declarations, diagnostics := parseImportSpecificationYAML(input)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("import parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
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())
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
const inheritanceKey = "extends"
|
||||
|
||||
@@ -66,13 +62,6 @@ type inheritanceContributor struct {
|
||||
trace *inheritanceTrace
|
||||
}
|
||||
|
||||
type siblingFrame struct {
|
||||
name string
|
||||
parent string
|
||||
hasParent bool
|
||||
entered bool
|
||||
}
|
||||
|
||||
// expandSiblingDefinitions resolves only the mappings directly in scope.
|
||||
// Nested instructions remain in the returned templates for destination-local
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
for _, entry := range node.Mapping {
|
||||
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.
|
||||
type LayeredOptions[T any] struct {
|
||||
Definition Definition[T]
|
||||
importDefaults importDefaults
|
||||
SourceVersionMode sourceVersionMode
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
@@ -40,6 +41,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
destination: main.Destination.clone(), placementMode: main.placementMode,
|
||||
},
|
||||
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||
importDefaults: snapshotImportDefaults(options.importDefaults),
|
||||
overlay: options.Overlay,
|
||||
overlayFilesystem: options.OverlayFilesystem,
|
||||
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.
|
||||
// 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.
|
||||
type Options[T any] struct {
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
SourceVersionMode sourceVersionMode
|
||||
destination Path
|
||||
placementMode placementMode
|
||||
sequencePolicy sequencePolicy
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
SourceVersionMode sourceVersionMode
|
||||
importDefaults importDefaults
|
||||
destination Path
|
||||
placementMode placementMode
|
||||
sequencePolicy sequencePolicy
|
||||
deferAssembly bool
|
||||
importDefaultsValidated bool
|
||||
}
|
||||
|
||||
// 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
|
||||
request := loadRequest[T]{
|
||||
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,
|
||||
sourceVersionMode: options.SourceVersionMode,
|
||||
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{}
|
||||
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.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||
mainOptions.sequencePolicy = request.sequencePolicy
|
||||
mainOptions.SourceVersionMode = request.sourceVersionMode
|
||||
mainOptions.Definition.Version = request.protectionVersion()
|
||||
@@ -182,6 +186,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
}
|
||||
versionState := &sourceVersionState{
|
||||
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
||||
importDefaults: snapshotImportDefaults(request.importDefaults),
|
||||
}
|
||||
accumulator.versionState = 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.
|
||||
type acquiredContribution struct {
|
||||
source SourceInfo
|
||||
identity importNodeKey
|
||||
role importContributionRole
|
||||
scopeOwner importNodeKey
|
||||
payload *document.Node
|
||||
document *document.Node
|
||||
destination Path
|
||||
relativeDestination Path
|
||||
processing *sourceVersionProcessingResult
|
||||
nested *composedDocument
|
||||
pendingItem *acquisitionScope
|
||||
placementCharged bool
|
||||
}
|
||||
|
||||
// acquisitionScope is the ordered strict mapping scope produced by one input
|
||||
// and its ordinary imports.
|
||||
type acquisitionScope struct {
|
||||
owner importNodeKey
|
||||
destination Path
|
||||
contributions []acquiredContribution
|
||||
}
|
||||
|
||||
func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope {
|
||||
cloned := acquisitionScope{destination: scope.destination.clone()}
|
||||
cloned := acquisitionScope{owner: scope.owner, destination: scope.destination.clone()}
|
||||
if scope.contributions != nil {
|
||||
cloned.contributions = make([]acquiredContribution, len(scope.contributions))
|
||||
for index, contribution := range scope.contributions {
|
||||
cloned.contributions[index] = acquiredContribution{
|
||||
source: cloneSourceInfo(contribution.source), payload: contribution.payload,
|
||||
source: cloneSourceInfo(contribution.source), identity: contribution.identity,
|
||||
role: contribution.role, scopeOwner: contribution.scopeOwner, payload: contribution.payload,
|
||||
document: contribution.document, destination: contribution.destination.clone(),
|
||||
relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing,
|
||||
nested: cloneComposedDocument(contribution.nested),
|
||||
nested: cloneComposedDocument(contribution.nested), pendingItem: cloneAcquisitionScopePointer(contribution.pendingItem),
|
||||
placementCharged: contribution.placementCharged,
|
||||
}
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneAcquisitionScopePointer(scope *acquisitionScope) *acquisitionScope {
|
||||
if scope == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := cloneAcquisitionScope(*scope)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
|
||||
if scopes == nil {
|
||||
return nil
|
||||
@@ -121,12 +137,18 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
}
|
||||
}
|
||||
if options.Imports {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, 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 {
|
||||
return input, diagnostics
|
||||
}
|
||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||
input.document = input.composition.root
|
||||
if input.composition != nil {
|
||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||
input.document = input.composition.root
|
||||
}
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if versionProcessing != nil {
|
||||
@@ -145,10 +167,13 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
}
|
||||
localPayload := input.document
|
||||
input.document = placed
|
||||
identity := importKeyForMainSource(parsed.source)
|
||||
input.acquisition = []acquisitionScope{{
|
||||
owner: identity,
|
||||
destination: destination.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||
document: placed, destination: destination.clone(), processing: versionProcessing,
|
||||
}},
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,7 @@ type loadRequest[T any] struct {
|
||||
main loadInput
|
||||
underlays []loadGroup
|
||||
definition Definition[T]
|
||||
importDefaults importDefaults
|
||||
sequencePolicy sequencePolicy
|
||||
sourceVersionMode sourceVersionMode
|
||||
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) {
|
||||
request.ordinary = false
|
||||
var diagnostics []Diagnostic
|
||||
request.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
|
||||
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
|
||||
request.sequencePolicy = policy
|
||||
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]{{
|
||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
||||
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
||||
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
||||
}})
|
||||
request.main.destination = mainOptions.destination
|
||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||
@@ -53,7 +57,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
||||
for groupIndex := range request.underlays {
|
||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||
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
|
||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||
}
|
||||
@@ -158,14 +165,29 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
||||
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
||||
visited := make(map[resourceIdentity]importRoute)
|
||||
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
|
||||
inclusionGraph := newImportInclusionGraph()
|
||||
var sources []SourceInfo
|
||||
var contexts map[string]ResourceContext
|
||||
for _, contribution := range group.inputs {
|
||||
for inputIndex, contribution := range group.inputs {
|
||||
options := Options[struct{}]{
|
||||
Imports: contribution.imports, Filesystem: contribution.filesystem,
|
||||
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)
|
||||
for _, source := range acquired.sources {
|
||||
@@ -177,16 +199,37 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, sources, contexts, projectPriorInputDiagnosticPaths(diagnostics, scopes)
|
||||
}
|
||||
if acquired.document == nil {
|
||||
if acquired.document == nil && len(acquired.acquisition) == 0 {
|
||||
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
|
||||
for _, scope := range scopes {
|
||||
for _, contribution := range scope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
for _, contribution := range groupScope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
if len(documents) == 0 {
|
||||
return nil, sources, contexts, nil
|
||||
@@ -197,6 +240,18 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
||||
}
|
||||
if composition != nil && rule != nil {
|
||||
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 {
|
||||
if node := fragmentVersionClaimWithIndex(document, rule, sourceIndex, ranges); node != nil {
|
||||
location := publicLocation(node.Location)
|
||||
@@ -209,13 +264,14 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
||||
}
|
||||
composition.inputBytes = accounting.bytes - initialBytes
|
||||
composition.inputNodes = accounting.nodes - initialNodes
|
||||
composition.acquisition = cloneAcquisitionScopes(scopes)
|
||||
var projectionScope acquisitionScope
|
||||
for _, scope := range scopes {
|
||||
projectionScope.contributions = append(projectionScope.contributions, scope.contributions...)
|
||||
groupScope.owner = importNodeKey{namespaceKind: "group", namespaceAnchor: explicitNamespaceAnchor(index, 0), resourceKind: "scope"}
|
||||
for contributionIndex := range groupScope.contributions {
|
||||
groupScope.contributions[contributionIndex].scopeOwner = groupScope.owner
|
||||
}
|
||||
projectNestedEvidence(composition, projectionScope)
|
||||
projectAcquiredDestinations(composition, projectionScope, sources)
|
||||
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(groupScope)}
|
||||
composition.importGraph = inclusionGraph.clone()
|
||||
projectNestedEvidence(composition, groupScope)
|
||||
projectAcquiredDestinations(composition, groupScope, sources)
|
||||
for pointer, origin := range composition.origins {
|
||||
origin.Layer = LayerUnderlay
|
||||
composition.origins[pointer] = origin
|
||||
@@ -225,3 +281,13 @@ func acquireUnderlayGroupWithPolicyVersion(group loadGroup, index int, rule *Ver
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
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})
|
||||
if err == nil {
|
||||
t.Fatal("missing virtual import fell back to the host filesystem")
|
||||
@@ -170,9 +170,7 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
||||
imports:
|
||||
- path: ../shared.yml
|
||||
- path: absent.yml
|
||||
optional: true
|
||||
- glob: absent/*.yml
|
||||
optional: true
|
||||
`)},
|
||||
"config/shared.yml": &fstest.MapFile{Data: []byte("items: {shared: retained}\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 {
|
||||
options = supplied[0]
|
||||
}
|
||||
options.importDefaults = snapshotImportDefaults(options.importDefaults)
|
||||
var diagnostics []Diagnostic
|
||||
if !options.importDefaultsValidated {
|
||||
diagnostics = validateImportDefaults(options.importDefaults)
|
||||
}
|
||||
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
||||
}
|
||||
|
||||
@@ -192,11 +192,105 @@ type SourceInfo struct {
|
||||
UnderlayIndex int
|
||||
ImportedFrom *Location
|
||||
VersionCheck *SourceVersionCheck
|
||||
importSelections []importSelectionEvidence
|
||||
namespace *namespaceToken
|
||||
placementMode placementMode
|
||||
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
|
||||
// root-owned numeric representation.
|
||||
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||
@@ -293,6 +387,14 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
source.Destination = source.Destination.clone()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\nitems: [{id: authored}]\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "items: [{id: imported}]\n")
|
||||
result, err := testLoad[config](File(mainPath), Options[config]{Imports: true, Definition: definition})
|
||||
if err != nil || len(result.Config.Items) != 2 || result.Config.Items[1].ID != "imported" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,9 +23,16 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
|
||||
filesystem = context.FS.namespace.filesystem
|
||||
rootPath, basePath = context.Root, context.BaseDir
|
||||
} else if source.kind == sourceFile {
|
||||
filePath := sources[0].Path
|
||||
filePath := source.path
|
||||
if baseSource != nil {
|
||||
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)
|
||||
rootPath = basePath
|
||||
|
||||
@@ -47,6 +47,7 @@ type sourceVersionState struct {
|
||||
localRule *VersionRule
|
||||
effectiveRule *VersionRule
|
||||
mode sourceVersionMode
|
||||
importDefaults importDefaults
|
||||
mainProcessing *sourceVersionProcessingResult
|
||||
evidence []*sourceVersionEvidence
|
||||
diagnostics []Diagnostic
|
||||
@@ -58,15 +59,18 @@ func sourceInfosWithVersionEvidence(sources []SourceInfo, evidence []*sourceVers
|
||||
return result
|
||||
}
|
||||
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 {
|
||||
if item == nil || item.sourceID == "" {
|
||||
continue
|
||||
}
|
||||
for index := range result {
|
||||
if result[index].ID == item.sourceID {
|
||||
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
|
||||
break
|
||||
}
|
||||
if index, found := sourceIndexes[item.sourceID]; found {
|
||||
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -136,7 +140,7 @@ func (state *sourceVersionState) process(root *document.Node, role sourceVersion
|
||||
|
||||
type sourceVersionProcessingResult struct {
|
||||
payload *document.Node
|
||||
imports []importDeclaration
|
||||
imports []importDeclarationSpec
|
||||
evidence *sourceVersionEvidence
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
@@ -880,7 +880,6 @@ func TestSourceVersionImportsRejectHiddenAndMissingDeclarations(t *testing.T) {
|
||||
configkit:
|
||||
imports:
|
||||
- path: missing.yml
|
||||
optional: true
|
||||
`)
|
||||
checks := 0
|
||||
loaded, err := Load(File(mainPath), testSecretBinding(), Options[sourceVersionNullConfig]{
|
||||
|
||||
@@ -35,6 +35,9 @@ type Underlay struct{ Inputs []Input }
|
||||
type LayeredOptions[T any] struct {
|
||||
Definition Definition[T]
|
||||
SourceVersionMode SourceVersionMode
|
||||
// ImportDefaults supplies one load-wide default policy for enabled main,
|
||||
// underlay, and transitive declarations. Explicit declaration values win.
|
||||
ImportDefaults ImportDefaults
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
OverlayFilesystem *FilesystemContext
|
||||
@@ -73,6 +76,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
||||
Overlay: options.Overlay,
|
||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||
}
|
||||
result = engine.LayeredOptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
|
||||
if options.Underlays != nil {
|
||||
result.Underlays = make([]engine.Underlay, len(options.Underlays))
|
||||
for index, group := range options.Underlays {
|
||||
|
||||
34
lifecycle.go
34
lifecycle.go
@@ -72,8 +72,31 @@ const (
|
||||
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.
|
||||
// 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.
|
||||
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||
// document's local coordinates. Placement projects checks and main protection
|
||||
@@ -86,8 +109,12 @@ type Options[T any] struct {
|
||||
Destination Path
|
||||
// PlacementMode defaults to mapping placement; unknown values and root item
|
||||
// placement fail before acquisition.
|
||||
PlacementMode PlacementMode
|
||||
Imports bool
|
||||
PlacementMode PlacementMode
|
||||
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
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
@@ -301,6 +328,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||
}
|
||||
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
||||
result = engine.OptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
|
||||
if options.Filesystem != nil {
|
||||
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
|
||||
// records reflect completed sensitivity classification.
|
||||
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
|
||||
@@ -295,9 +331,22 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.Destination = source.Destination.clone()
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||
source.ImportSelections = cloneImportSelections(source.ImportSelections)
|
||||
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 {
|
||||
if value == 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)
|
||||
}
|
||||
}
|
||||
|
||||
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