90 Commits

Author SHA1 Message Date
bc6b977585 Split the sequence implementation into smaller stages 2026-10-23 13:40:29 +00:00
d06aae5461 Plan sequence identity and combination implementation 2026-10-23 12:56:23 +00:00
4f941f8ba4 Clarify sequence policy interactions and rename the roadmap 2026-10-23 12:20:18 +00:00
9a49833339 Remove the completed operator convention audit 2026-10-23 12:17:29 +00:00
749cf6868c Settle the sequence identity registration API 2026-10-23 12:09:44 +00:00
a5f0b92ee9 Add dynamic identity constraints to the sequence roadmap 2026-10-23 01:56:00 +00:00
6ec23b2de1 Extend collection inheritance architecture to identified sequences 2026-10-23 01:42:32 +00:00
3298eb916c Settle how sequence inheritance references compose 2026-10-23 01:40:37 +00:00
40430e787c Add identified record inheritance to the sequence roadmap 2026-10-23 01:31:22 +00:00
f434fa159f Align architecture policy with collection and CLI decisions 2026-10-23 01:12:11 +00:00
8ddf73ede3 Accept collection policy and CLI lifecycle decisions 2026-10-23 01:09:24 +00:00
844a15a3cb Consolidate the sequence roadmap around the settled design 2026-10-23 00:11:50 +00:00
58f4c39d6f Settle sequence identity declarations without values 2026-10-23 00:04:55 +00:00
8a54cecd1d Settle nested sequence policy ownership and record review findings 2026-10-23 00:02:26 +00:00
0e430e8077 Allow lower-tier identity during strict composition 2026-10-22 23:53:48 +00:00
26caa6024e Settle persistent sequence operation policy 2026-10-22 23:47:50 +00:00
8691a93ebf Settle CLI ordering before application resolution 2026-10-22 23:41:06 +00:00
c563f80934 Record remaining sequence lifecycle decisions 2026-10-22 15:45:21 +00:00
27797d2392 Settle sequence inheritance and lifecycle coverage 2026-10-22 15:16:42 +00:00
6a4e57e5d3 Settle sequence validation and absent value behavior 2026-10-22 14:47:00 +00:00
29f94d89e1 Settle sequence mode names and identity-based defaults 2026-10-22 14:39:40 +00:00
12f6045da0 Settle sequence combination scope and ordering 2026-10-22 14:08:55 +00:00
6eb80b06e3 Settle sequence identity definitions and policy lifetime 2026-10-22 13:28:18 +00:00
86feb6d755 Settle collection declaration syntax in the sequence roadmap 2026-10-21 14:00:02 +00:00
8bf49f6ea6 Include overlay author controls in the sequence proposal 2026-10-20 14:08:57 +00:00
f3d0fe6411 Record final identity guarantees for application registrations 2026-10-20 14:03:26 +00:00
cba3a0efb0 Record whole-record sequence replacement and application identity constraints 2026-10-20 13:57:19 +00:00
b58cd75c98 Draft sequence identity and layered append proposal 2026-10-20 13:48:54 +00:00
c530a989df Audit operator conventions against Ansible 2026-10-20 13:28:21 +00:00
2baf14d9a5 Retire completed import integration remediation 2026-10-20 04:11:54 +00:00
6903c9e51a Clarify empty path import inspection evidence 2026-10-20 01:06:15 +00:00
31e10af9cb Clarify scheduled provenance and history ordering 2026-10-20 01:05:34 +00:00
0d841d9d73 Resolve contained symlinks in import directory prefixes 2026-10-20 01:04:31 +00:00
4bcefbc9d4 Record import integration audit findings 2026-10-20 00:38:04 +00:00
91db99b967 Retire completed import discovery plans 2026-10-20 00:32:11 +00:00
5747ec49c6 Count import depth only for selected sources 2026-10-20 00:30:35 +00:00
d3a714a1cb Validate complete import depth numbers 2026-10-20 00:27:55 +00:00
7f8ad81065 Track active symlink expansions precisely 2026-10-20 00:26:30 +00:00
75da54150f Resolve native imports beneath nested roots 2026-10-20 00:23:55 +00:00
38fb315415 Check item version ownership at effective paths 2026-10-20 00:22:43 +00:00
1c79ec3aa1 Preserve native import alias identity 2026-10-20 00:20:38 +00:00
01e1b2fc2d Record import discovery review findings 2026-10-20 00:03:23 +00:00
15943e233c Record completed import roadmap 2026-10-19 21:23:54 +00:00
f6a6b0184b Finalize import discovery documentation and audits 2026-10-19 21:23:47 +00:00
898035d83a Record prompt catalog example completion 2026-10-19 21:15:53 +00:00
de3666aed3 Expand prompt catalog discovery example 2026-10-19 21:15:47 +00:00
c00811da2e Record lifecycle audit completion 2026-10-19 21:13:29 +00:00
55abcf8340 Audit scheduled import and inheritance lifecycles 2026-10-19 21:13:21 +00:00
9d7c79cf0b Record public import API completion 2026-10-19 21:06:39 +00:00
24131617c7 Expose import defaults and selection evidence 2026-10-19 21:06:31 +00:00
05b67cbfcf Record empty import evidence completion 2026-10-19 20:57:53 +00:00
e433988962 Retain empty optional import selection evidence 2026-10-19 20:57:41 +00:00
05a28d1178 Record final import wiring completion 2026-10-19 20:51:01 +00:00
1e5bffc0e6 Wire final import declarations through loading 2026-10-19 20:50:47 +00:00
f5a689b578 Record glob discovery completion 2026-10-19 20:38:48 +00:00
e4abff4164 Filter and account final glob candidates 2026-10-19 20:38:42 +00:00
1a8f1cc3a5 Record directory link discovery completion 2026-10-19 20:29:36 +00:00
9b99463187 Follow links safely during directory discovery 2026-10-19 20:29:20 +00:00
f69fa24b40 Record recursive directory search completion 2026-10-19 20:18:44 +00:00
77d97296ea Add recursive directory search with depth limits 2026-10-19 20:18:39 +00:00
032679bf34 Record shallow directory selection completion 2026-10-19 20:13:39 +00:00
55a59de6db Select shallow directory import sources 2026-10-19 20:13:33 +00:00
a0d13962bd Record named import selection completion 2026-10-19 20:08:05 +00:00
5f33e1e90c Select named imports before source admission 2026-10-19 20:07:54 +00:00
b750b0fec6 Record private import parser completion 2026-10-19 20:02:12 +00:00
b498e5bee5 Define private import defaults and declaration parsing 2026-10-19 20:02:05 +00:00
ba577429e6 Record underlay group scheduling completion 2026-10-19 19:53:08 +00:00
014b99504b Schedule underlay groups as dependency forests 2026-10-19 19:53:01 +00:00
263dd2f1bd Record item scope scheduling completion 2026-10-19 19:39:30 +00:00
af43d6f438 Schedule item-local import scopes 2026-10-19 19:39:19 +00:00
a4cbd6390e Record ordinary import scheduling completion 2026-10-19 19:29:52 +00:00
5ed0f55970 Schedule ordinary import contributions by dependency 2026-10-19 19:29:35 +00:00
91ab37a39d Record import graph completion 2026-10-19 17:34:45 +00:00
fbe9ae6d8b Retain import inclusion graph during acquisition 2026-10-19 17:34:21 +00:00
1aa121df71 Record scheduled inheritance completion 2026-10-19 17:25:32 +00:00
6e40c166d6 Schedule sibling inheritance by dependency readiness 2026-10-19 17:25:27 +00:00
a56d4691ff Record inheritance graph groundwork 2026-10-19 15:18:37 +00:00
2718fbb8fe Separate inheritance graph analysis and application 2026-10-19 15:18:25 +00:00
2d52421919 Record deterministic scheduler completion 2026-10-19 15:11:42 +00:00
167fcd475f Add deterministic dependency scheduler 2026-10-19 15:11:28 +00:00
aa93aeda3f Plan import discovery and shared dependency ordering 2026-10-19 14:33:01 +00:00
94381a67fd Record accepted import architecture decisions 2026-10-19 14:24:19 +00:00
c26b34147b Clarify import candidate identity, accounting, and inspection 2026-10-19 14:15:01 +00:00
c9f9c98fbe Settle import validation and require recursion for depth 2026-10-19 14:05:18 +00:00
f6bf4454b0 Settle import selector lists and exclusions 2026-10-19 13:43:56 +00:00
a7ca2c59a2 Settle import defaults, discovery boundaries, and inspection decisions 2026-10-19 01:54:01 +00:00
10c14aa794 Add architecture principles for simplicity and permissive defaults 2026-10-18 21:13:28 +00:00
e9774b252b Adopt shared dependency-ready ordering for imports and inheritance 2026-10-18 21:09:05 +00:00
19fe621df1 Apply permissive defaults and remove import transition support 2026-10-18 16:37:07 +00:00
82b289f4fe Clarify import roadmap decisions using Ansible conventions 2026-10-18 16:28:38 +00:00
80 changed files with 9134 additions and 720 deletions

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View 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.

View 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.

View 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.

View 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.

View File

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

View File

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

View File

@@ -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
View 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`.

View File

@@ -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.

View File

@@ -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.

View File

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

View 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
View 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.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
This file does not match the application import defaults.

View File

@@ -1,6 +1,6 @@
configkit:
imports:
- path: curated-id.yml
- path: helpers/curated-id.yml
name: Curated summary
messages:
- Summarize the supplied content.

View File

@@ -0,0 +1,3 @@
id: ignored
name: Excluded generated prompt
messages: [This record is excluded.]

View File

@@ -2,3 +2,4 @@ id: welcome
name: Welcome
messages:
- Welcome the user.
resource: welcome.txt

View File

@@ -0,0 +1,3 @@
id: required
name: Required prompt
messages: [Use the required prompt.]

View File

@@ -0,0 +1 @@
Welcome resource text.

View File

@@ -0,0 +1,7 @@
configkit:
imports:
- path: prompts/required.yml
required: true
destination: [prompts]
placement: sequence-item
prompts: []

View 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)
}
}
}

View 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)
}

View 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
View 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)
}
}
}
}

View 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)
}
})
}
}

View File

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

View File

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

View File

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

View 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)
}
}
}

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

View 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]
}
}

View File

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

View File

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

View File

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

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

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

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

View 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)
}

View 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)
}
}
})
}
}

View File

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

View 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)
}

View File

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

View 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())
}

View 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,
}
}

View 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,
}
}

View 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, "./") }

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

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

View File

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

View File

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

View File

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

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

View 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)
}
})
}
}

View File

@@ -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...),

View File

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

View File

@@ -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,
}},
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]{

View File

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

View File

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

View File

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

View File

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