Compare commits
17 Commits
v0.7.1
...
315f464dfc
| Author | SHA1 | Date | |
|---|---|---|---|
| 315f464dfc | |||
| a029a2a754 | |||
| 945e111c4e | |||
| 2d8df22dd1 | |||
| 53601e68ed | |||
| 751787ec24 | |||
| f50122d9f8 | |||
| c71faf2065 | |||
| 2f5d57ce1d | |||
| a1c557603f | |||
| a2fab66aba | |||
| 2f1f7f4b8a | |||
| 667a536ae7 | |||
| ea55ab43ce | |||
| d3d468a8b4 | |||
| 6353598bd6 | |||
| 3c2d7facb3 |
14
README.md
14
README.md
@@ -68,12 +68,14 @@ The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
||||
these priorities and how genuine new needs can justify extending the shared
|
||||
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
|
||||
|
||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple
|
||||
native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes.
|
||||
See the [development guide](docs/development.md) for repository orientation and the
|
||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||
steps.
|
||||
The [configuration contract](docs/config.md) describes implemented behavior,
|
||||
including [input destinations](docs/config.md#input-destinations). The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
||||
layered, and mounted-collection examples alongside the migration shapes. The
|
||||
accepted placement decision is recorded in [ADR 0011](docs/adr/0011-input-destinations.md).
|
||||
See the [development guide](docs/development.md) for repository orientation and
|
||||
the [release procedure](docs/release.md) for maintainer-only candidate and
|
||||
publication steps.
|
||||
|
||||
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ 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, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||
}
|
||||
func fromEngineOrigin(value engine.Origin) Origin {
|
||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||
|
||||
70
docs/adr/0011-input-destinations.md
Normal file
70
docs/adr/0011-input-destinations.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ADR 0011: Place Configuration Inputs at Mapping Destinations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. The [input-destinations roadmap](../roadmap/input-destinations.md)
|
||||
owns implementation status and the [implementation plan](../roadmap/implementation.md)
|
||||
owns sequencing and completion evidence.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-14
|
||||
|
||||
## Context
|
||||
|
||||
Consumers assembling collections of independently authored configuration
|
||||
fragments need each source to contribute beneath a selected application path.
|
||||
Requiring consumers to rewrite source documents or merge completed values would
|
||||
separate placement from ConfigKit's existing composition, precedence, source
|
||||
identity, and inspection behavior. Placement should remain part of the ordinary
|
||||
loading lifecycle and preserve the root-owned public facade.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an optional `Destination Path` to root-owned `Input` and expose the effective
|
||||
destination on existing `SourceInfo` records. The empty path means the existing
|
||||
application root. A non-empty destination is a sequence of literal mapping keys;
|
||||
explicit sequence-index segments are invalid. Existing reserved structural keys
|
||||
remain reserved, while keys that merely contain their spelling remain ordinary
|
||||
keys.
|
||||
|
||||
Acquire and interpret each source using its original physical identity and
|
||||
source-local directives, then place its application body before strict
|
||||
composition and the existing final lifecycle. Imported application bodies
|
||||
inherit the input destination. Placement applies to main and underlay inputs;
|
||||
ordinary root loading, selected overlays, and CLI inputs retain root placement.
|
||||
The main-owned version rule continues to check its effective application path
|
||||
at its existing lifecycle point.
|
||||
|
||||
Placement preserves authored node locations and source identity. Introduced
|
||||
mapping structure belongs to the contributing source and uses that document's
|
||||
root location as its source-coordinate fallback. A present empty mapping
|
||||
materializes its destination; an absent `NoSource` contributes nothing. The
|
||||
result continues through the existing strict composition, overlay, inheritance,
|
||||
typed loading, diagnostics, provenance, history, sensitivity, and resource
|
||||
context mechanisms.
|
||||
|
||||
The added exported fields preserve keyed and zero-value struct use. Callers
|
||||
using unkeyed composite literals for `Input` or `SourceInfo` must migrate to
|
||||
keyed literals. Treat this feature as minor-release work; select the version and
|
||||
document the compatibility impact during separately authorized release
|
||||
preparation, consistent with the release policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Rewrite or merge sources in consumers.** This makes each consumer reproduce
|
||||
placement, strict composition, source attribution, and inspection semantics.
|
||||
- **Add a separate loading entry point or public wrapper type.** This creates a
|
||||
parallel API for a behavior that belongs to existing per-input loading.
|
||||
- **Load each fragment as a complete application before placement.** This runs
|
||||
defaults, decoding, resolution, and validation on incomplete fragments rather
|
||||
than once on the assembled application.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can assemble contributions at effective mapping paths in one ordinary
|
||||
layered load and inspect each source's placement through existing source
|
||||
records. Paths, conflicts, and later application operations use the placed
|
||||
structure while source coordinates and relative-resource contexts continue to
|
||||
identify their original sources. Public field additions require release-note
|
||||
guidance for callers using unkeyed literals.
|
||||
@@ -149,6 +149,33 @@ have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
||||
Byte, source, node, and glob-entry budgets are shared across the load, while
|
||||
the existing per-source and import-depth bounds still apply.
|
||||
|
||||
#### Input destinations
|
||||
|
||||
`Input.Destination` places the source's application mapping beneath a path
|
||||
relative to the configuration root. Its zero or empty value means root, so
|
||||
existing inputs keep their behavior. Main inputs and underlay inputs use the
|
||||
same destination model. Destination segments are literal mapping keys: dots,
|
||||
slashes, and numeric-looking text are not split or interpreted as sequence
|
||||
indexes. Explicit index segments and paths that introduce the reserved
|
||||
`configkit` or mapping-valued `extends` structure are invalid input options and
|
||||
produce a structured `LoadError` before source acquisition.
|
||||
|
||||
Source-root `configkit.imports` directives are processed before placement, and
|
||||
each imported application contribution inherits its input's destination,
|
||||
including transitive imports. A valid destination on `NoSource()` contributes
|
||||
nothing; an actual empty mapping materializes its destination containers. Once
|
||||
placed, contributions follow the same strict composition within a tier and
|
||||
recursive precedence behavior described above. A selected overlay and CLI
|
||||
overrides remain rooted at the application root and are not moved beneath the
|
||||
main input's destination.
|
||||
|
||||
`SourceInfo.Destination` reports the placement for each acquired input source,
|
||||
including imports. Placement does not change source IDs, acquisition identity,
|
||||
physical source locations, or passive resource contexts. Introduced ancestor
|
||||
mappings use the contributing document's root location as a source fallback;
|
||||
authored content retains its original source coordinates. Returned source
|
||||
records are detached copies.
|
||||
|
||||
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||
and explicit resource access without a second configuration pass.
|
||||
|
||||
@@ -17,6 +17,7 @@ sanitized shapes using only ConfigKit and the Go standard library:
|
||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
||||
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
|
||||
| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
|
||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||
|
||||
@@ -108,6 +109,37 @@ and may fail independently of the completed configuration load. The
|
||||
[configuration contract](../config.md#source-resource-contexts) owns exact
|
||||
lifetime and precedence rules.
|
||||
|
||||
## Input destinations and mounted collections
|
||||
|
||||
Main and underlay inputs use the same destination field; imported application
|
||||
contributions inherit the declaring input's destination. A destination is an
|
||||
application mapping path, independent of filesystem context. The configuration
|
||||
contract owns the complete behavior and validation rules.
|
||||
|
||||
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
|
||||
mounts one mapping source beneath literal collection/prompt keys and loads a
|
||||
second input without a destination to show that root placement remains the
|
||||
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
|
||||
to interpret.
|
||||
|
||||
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
|
||||
embedded collection manifest with a source-root import, separate prompt and
|
||||
profile peers, and a higher-priority input that changes only one prompt's
|
||||
ordinary `version` field. The main document alone supplies the application
|
||||
schema version. A prompt's local `extends` relationship resolves after all
|
||||
tiers, so `custom` inherits the final `brief` content and fields while retaining
|
||||
its own profile selection.
|
||||
|
||||
The resolver follows each effective `content_file` origin to its declaring
|
||||
source context. This also works for `custom`'s inherited resource. The
|
||||
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
|
||||
demonstrates the same lookup after loading; ConfigKit does not open or interpret
|
||||
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
|
||||
checks source destinations and authored coordinates, presence, inherited values,
|
||||
relative resources, and strict peer conflicts. The
|
||||
[configuration contract](../config.md#input-destinations) owns shared placement,
|
||||
import, source-location, and ownership behavior.
|
||||
|
||||
Known module catalogs should use canonical object entries with a module identifier
|
||||
and typed options. A small union struct is sufficient when the available option
|
||||
fields are known; validation selects which fields apply to each module. Runtime-
|
||||
@@ -187,6 +219,7 @@ focused behavioral owner:
|
||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
|
||||
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
|
||||
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
|
||||
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
|
||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||
|
||||
@@ -39,6 +39,7 @@ public behavior.
|
||||
| Task | Read |
|
||||
| --- | --- |
|
||||
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
||||
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
|
||||
| Documentation changes | The owning document |
|
||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
||||
@@ -55,6 +56,7 @@ Accepted decisions:
|
||||
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||
- [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)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
@@ -164,6 +166,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| 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'` |
|
||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||
| Input destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier and import tests](../internal/engine/destination_tiers_test.go), [integration and provenance tests](../internal/engine/destination_integration_test.go), [public facade tests](../facade_destination_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Mounted'`, `go test . -run 'Destination|FacadeMounted'`, and `go test ./examples/consumers -run 'CollectionBundle|CollectionPeer'` |
|
||||
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||
|
||||
@@ -8,8 +8,10 @@ private state; public signatures and fields do not expose engine types.
|
||||
Implemented component owners:
|
||||
|
||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||
grouped strict composition, recursive precedence assembly, and destination-local
|
||||
sibling inheritance before typed decoding. It also owns schema
|
||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
||||
placement within that assembly, and destination-local sibling inheritance
|
||||
before typed decoding. It retains source placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
provenance/history, passive source-context retention, secret resolution and
|
||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
||||
@@ -41,11 +43,13 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
||||
internal types.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||
semantics, including [effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||
semantics, including [input destinations](../config.md#input-destinations) and
|
||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||
The [architecture policy](../policy/architecture.md) owns contributor
|
||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
|
||||
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
|
||||
decision. See the [development guide](../development.md) for validation commands
|
||||
and [release procedure](../release.md) for source-candidate gates. [ADR
|
||||
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
|
||||
|
||||
@@ -99,6 +99,13 @@ types, field meanings, defaults, normalization, semantic validation, and schema
|
||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
||||
remote acquisition, runtime initialization, and resource checks.
|
||||
|
||||
The engine applies each input's mapping destination within the existing source
|
||||
assembly and retains that placement with source identity and inspection records;
|
||||
the root facade owns the public fields and conversions. The
|
||||
[configuration contract](../config.md#input-destinations) defines the observable
|
||||
placement behavior, and [ADR 0011](../adr/0011-input-destinations.md) records the
|
||||
accepted decision.
|
||||
|
||||
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`.
|
||||
|
||||
580
docs/roadmap/implementation.md
Normal file
580
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,580 @@
|
||||
# Implement configuration input destinations
|
||||
|
||||
Status: complete. Stages 1–12 are implemented and audited. The
|
||||
[feature roadmap](input-destinations.md) owns the approved behavior and rationale; the
|
||||
[consumer request](promptkit-feature-request.md#public-acceptance-scenarios)
|
||||
supplies the public acceptance scenarios. This document owns execution and
|
||||
completion evidence. Planning baseline: `6353598`.
|
||||
|
||||
## Working instructions
|
||||
|
||||
- Start each stage with `AGENTS.md`, [development.md](../development.md), the
|
||||
applicable policies, and working-tree inspection. Use graph-first code
|
||||
discovery. Preserve unrelated changes and previously completed stages.
|
||||
- Implement only that stage and its supporting tests/documentation. Mark it
|
||||
complete here with actual commands and evidence, then commit the nonempty
|
||||
validated unit with a short plain-English message. Subsequent stages remain
|
||||
planned. A stage is not complete solely because its narrow tests pass.
|
||||
- Keep early mechanics private until the complete engine behavior is ready.
|
||||
Root destination fields land in Stage 10. Ordinary root loading must remain
|
||||
working throughout. Do not publish a partially supported public placement API.
|
||||
- Follow the existing engine/document/YAML boundaries. Reuse strict composition,
|
||||
recursive overlay, sibling inheritance, paths, history, and sensitivity.
|
||||
No dependencies, toolchain changes, alternate mergers, mount registry,
|
||||
per-fragment lifecycle, import-destination syntax, resource discovery, or new
|
||||
release gates are needed. These are scope boundaries, not new prohibitions
|
||||
on separately approved future work.
|
||||
- Update current-behavior documentation when public behavior lands; keep future
|
||||
claims here or in the roadmap until then. Add the ADR in Stage 1 under the
|
||||
documentation policy's accepted-decision exception.
|
||||
- Use focused offline tests while iterating; run `./scripts/check.sh` before
|
||||
committing production changes. Documentation-only stages need whitespace and
|
||||
local-link checks. Stage 12 adds full ownership/race validation. Tests protect
|
||||
observable behavior, not wrapper allocation or helper-call counts. Reuse
|
||||
coverage rather than repeat an entire loading matrix at every layer.
|
||||
- No benchmark campaign or public complexity guarantee is required. Prefer
|
||||
straightforward bounded walks and wrapping each acquired contribution once;
|
||||
avoid rescanning all preceding contributions for every new input.
|
||||
- Do not push, tag, publish, update downstream dependencies, or retire unfinished
|
||||
records. Release compatibility review is separate from publication.
|
||||
|
||||
## Resolved implementation decisions
|
||||
|
||||
1. Add root-owned `Input.Destination Path` and `SourceInfo.Destination Path`,
|
||||
mirrored privately in engine input/source records. Extend `loadInput` and
|
||||
explicit root/engine conversion and cloning. No new loading entry point,
|
||||
public wrapper type, option, or origin field is needed. Ordinary `Load`
|
||||
continues to supply root placement.
|
||||
2. The zero path means root. Validate `Path.valid()` and reject every explicit
|
||||
index segment, including a negative index. Normalize parsed tokens to literal
|
||||
mapping-key segments: `/0` means the key `0`, not sequence element zero.
|
||||
Empty, dot, dot-dot, slash-containing, tilde-containing, and numeric keys keep
|
||||
existing semantic-path meaning. Do not split dots, interpret filesystem
|
||||
traversal, enforce identifier grammar, or add namespace uniqueness.
|
||||
3. Reject destination segments equal to `configkit` or `extends`: placement
|
||||
would introduce a reserved mapping key with a mapping value. Substrings such
|
||||
as `myextends` are ordinary keys. This preserves the approved structural
|
||||
rules rather than adding an application naming policy. Source-root import
|
||||
directives are still processed before placement.
|
||||
4. Validate destination shape/reservations for all declared inputs during
|
||||
grouped option preflight, before reading any source. Reuse
|
||||
`CodeOptionsInvalid` for invalid destination declarations; include the
|
||||
offending semantic path when representable, with no fabricated physical
|
||||
location. An invalid source still follows existing source-option validation.
|
||||
A valid destination on `NoSource` contributes no nodes, source record, or
|
||||
budget charges. No extra error type or exact message contract is introduced.
|
||||
5. Separate acquired source syntax from application placement. Retain original
|
||||
parsed source metadata for filesystem/directive handling. Strip source-root
|
||||
directives, then wrap each application body at the normalized destination
|
||||
before fragment version checks and strict composition. Imports inherit the
|
||||
same destination transitively. Do not wrap both a source application and its
|
||||
recomposed result; every retained application must already use effective
|
||||
coordinates. Content roots remain mappings under existing source contracts.
|
||||
6. Wrapping a body at `Keys("a", "b")` creates `{a: {b: body}}` as private
|
||||
document mappings. Actual body nodes and entries keep their physical source
|
||||
coordinates. Introduced mappings/entries use the contributing document's root
|
||||
location as a documented source fallback, not purported authored coordinates.
|
||||
They have the same contributing source ID and create no synthetic source.
|
||||
Retained history trees remain immutable and separate from assembly mutation.
|
||||
7. A present empty body creates explicit destination mappings, including an
|
||||
import-only source whose application body is empty. It still cannot clear
|
||||
lower mappings. A missing optional import and `NoSource` remain absent. Empty
|
||||
source text remains the existing invalid document, not an empty mapping.
|
||||
8. `VersionRule.Path` stays an effective application path. Check main once after
|
||||
its own imports compose and before underlays/overlay/CLI, at the existing
|
||||
phase. Imported and underlay claims are checked against placed application
|
||||
content, including scalar/null/sequence ancestors replacing a protected path.
|
||||
A mapping ancestor with no protected descendant is not a version claim.
|
||||
Mounted domain `version` fields are ordinary data. Existing inheritance,
|
||||
selected-overlay, CLI protection and exact-path numeric conversion remain;
|
||||
no nested blanket conversion or additional fragment checker is introduced.
|
||||
9. Placement does not alter physical source identity, filesystem namespace,
|
||||
source IDs, acquisition order, imported-from locations, or resource contexts.
|
||||
Repeated physical sources in one strict group still fail even at different
|
||||
destinations. The same physical source in separate tiers retains the existing
|
||||
independent source applications/records and each record's own destination.
|
||||
Overlay and CLI source records have root `Destination`; they are not mounted
|
||||
merely because main is mounted. Active layered overlays retain their explicit
|
||||
independent filesystem selection and application-root meaning.
|
||||
10. Value-level paths use effective coordinates. Keep genuine source-local
|
||||
import-directive diagnostics and parse/read failures at their original
|
||||
locations; do not invent a logical child path when parsing cannot identify
|
||||
one. Prefix existing application-relative diagnostic paths exactly once
|
||||
where available. Preserve structured codes, related physical locations,
|
||||
deterministic ordering, and value-free messages.
|
||||
11. Use current depth/node limits, not a new destination cap. With root depth
|
||||
zero, each destination segment adds one level and, under `countInputNodes`,
|
||||
one mapping node plus one key. Retain current raw input accounting (including
|
||||
directives); charge only the additional `2 * segmentCount` synthetic nodes
|
||||
per acquired body, once, to the shared input budget. Placement adds no bytes,
|
||||
acquired sources, import nesting, or glob visits. Validate placed application
|
||||
depth and per-document node bounds before composition; share accounting
|
||||
across inputs and imports. Precheck wrapper depth/count before allocating
|
||||
wrappers. Use existing source/import limit diagnostics in their acquisition
|
||||
contexts; retain projection, expansion, and history bounds independently.
|
||||
Root placement adds no charge or new rejection. NoSource adds none either.
|
||||
12. Cloned source records include independently owned destination paths at every
|
||||
existing publication boundary: results, load errors, metadata/inspection,
|
||||
callback input information, and root conversion. Passive resource contexts
|
||||
remain keyed by the real source identity; inspection must work without an
|
||||
open configuration handle after load. This does not extend caller-owned
|
||||
filesystem or callback resource lifetimes.
|
||||
|
||||
## Stage 1 — Record the decision and capture the baseline
|
||||
|
||||
Status: complete. Scope: decision record and evidence, no feature code.
|
||||
|
||||
1. Read the roadmap, request, relevant policies, and ADRs 0001, 0003, 0006,
|
||||
0008, and 0009. Inspect the owners identified in the roadmap and the function
|
||||
map below. Confirm the baseline and worktree before starting.
|
||||
2. Run `./scripts/check.sh`; record the commit, Go version, and results here.
|
||||
Locate existing coverage for root loading, strict underlays, version ownership,
|
||||
repeated sources, history, resource contexts, and detached records.
|
||||
3. Write `docs/adr/0011-input-destinations.md` after confirming the number is
|
||||
unused. Record the approved input-placement decision as Accepted, with the
|
||||
roadmap owning implementation status. Follow the repository ADR format.
|
||||
Focus on ConfigKit, put rejected options only in Alternatives Considered,
|
||||
and avoid negative parallelism elsewhere. No PromptKit-specific semantics.
|
||||
4. Record the compatibility assessment under the release policy: adding fields
|
||||
preserves zero-value/keyed-literal use, but callers using unkeyed literals
|
||||
for `Input` or `SourceInfo` must migrate to keyed literals. Treat the feature
|
||||
as minor-release work; select a version and write its note only during
|
||||
separately authorized release preparation.
|
||||
|
||||
Validated: `./scripts/check.sh` (go1.26.8 linux/amd64) passed on candidate
|
||||
`d3d468a8b47d780787fca329ff65b107d98ca59f`; this includes formatting,
|
||||
whitespace, local documentation links, tests, vet, and build. The planning
|
||||
baseline `6353598bd6cce2eb78a5fe990e144cc0cadf1280` is present in history. The
|
||||
worktree was clean before this stage.
|
||||
|
||||
Coverage located for later destination work:
|
||||
|
||||
- Root and equivalent source loading: `TestEquivalentSourceFormsPreserveInputSemantics`
|
||||
(`facade_equivalence_test.go`) and
|
||||
`TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad` (`facade_layered_test.go`).
|
||||
- Strict underlays and group budgets: `TestGroupedLoadStrictConflictsRepetitionAndBudgets`
|
||||
and `TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle`
|
||||
(`internal/engine/load_request_test.go`).
|
||||
- Version ownership and imported claims: `TestLoadRejectsImportConflictsAndFragmentVersions`
|
||||
(`internal/engine/imports_test.go`) and version-path coverage in
|
||||
`internal/engine/load_test.go`.
|
||||
- Repeated physical sources: `TestRepeatedImportsAcrossTiersAndReaderConsumption`
|
||||
(`facade_equivalence_test.go`).
|
||||
- History and source contexts: `TestUnderlayHistoryRetainsLaterMappingContributors`
|
||||
(`facade_underlay_origins_test.go`) and
|
||||
`TestFacadeSourceContextsFollowOriginsWithoutQueryIO`
|
||||
(`facade_resource_context_test.go`).
|
||||
- Detached records: `TestLoadMetadataReflectsDefaultsInputAndDetachedRecords`
|
||||
(`internal/engine/load_test.go`) and
|
||||
`TestMetadataQueriesReturnDetachedRecords` (`internal/engine/metadata_test.go`).
|
||||
|
||||
The decision is recorded in [ADR 0011](../adr/0011-input-destinations.md).
|
||||
Compatibility assessment: adding the two public fields breaks callers using
|
||||
unkeyed literals for `Input` or `SourceInfo`; keyed and zero-value use remains
|
||||
compatible. The release policy permits the intentional pre-1.0 contract change
|
||||
in a minor release when the release note states impact and migration. Version
|
||||
selection and release-note preparation remain out of scope until separately
|
||||
authorized. No feature code or current behavior contract changed.
|
||||
|
||||
## Stage 2 — Add private destination normalization and wrapping
|
||||
|
||||
Status: complete. Scope: pure private mechanics with bounded unit tests.
|
||||
|
||||
1. Add a small engine helper file for normalized mapping-only destinations and
|
||||
constructing placed document bodies. Follow decisions 2–7; keep helpers
|
||||
private and avoid introducing a second path abstraction.
|
||||
2. Validate invalid paths, explicit indices, and exact reserved tokens. Normalize
|
||||
parsed numeric tokens into keys, preserving literal text and pointer escaping.
|
||||
3. Build wrappers without mutating the supplied tree. Preserve body locations;
|
||||
give introduced entries/mappings the source-root fallback. Implement depth and
|
||||
node measurement/prechecks usable with existing limits, including the extra
|
||||
synthetic-node count; aggregate charging is wired in Stage 7.
|
||||
4. Cover root identity/equivalence, nested empty mapping, literal/escaped/numeric
|
||||
keys, invalid index/path, exact versus substring reserved keys, owned paths,
|
||||
unchanged body coordinates, and bounded wrapper checks. Do not count helper
|
||||
invocations or fix an allocation strategy in tests.
|
||||
|
||||
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument'`
|
||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. The private
|
||||
helpers normalize mapping-only destinations, preserve authored nodes and source
|
||||
coordinates, and check per-document depth/node bounds before allocating wrappers.
|
||||
No loader accepts a new public field yet.
|
||||
|
||||
## Stage 3 — Integrate private non-import input placement
|
||||
|
||||
Status: complete. Scope: engine request plumbing and ordinary acquired bodies.
|
||||
|
||||
1. Add destination fields to engine `Input`, `loadInput`, and `SourceInfo`.
|
||||
Preserve ownership through engine `LoadLayered` request construction,
|
||||
`cloneSourceInfo`, and source-record copies. Keep the root facade unchanged.
|
||||
2. Extend grouped preflight to validate/normalize destinations before source
|
||||
reads. Use the existing diagnostic accumulator and options-invalid category.
|
||||
Ordinary `Load` supplies root placement through its existing request adapter.
|
||||
3. Thread placement into `assembleBaseInputTracked` without confusing raw
|
||||
`parsedSource` with effective `assembledInput.document`. Preserve the root
|
||||
path through existing wrappers/callers. For imports-disabled acquisition,
|
||||
validate disabled directives on raw content, place the body, and retain the
|
||||
destination on the acquired source record. NoSource still short-circuits.
|
||||
4. Use engine loads to cover mounted main and underlay bodies, omitted versus
|
||||
explicit root destinations, empty mapping versus NoSource, and preflight
|
||||
rejection without consuming a test-owned reader. Reuse current source-kind
|
||||
acquisition tests; full source equivalence is verified publicly in Stage 10.
|
||||
|
||||
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Grouped'`
|
||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Private non-import
|
||||
placement reaches assembly, with root behavior unchanged. Imports are the next
|
||||
private integration step.
|
||||
|
||||
## Stage 4 — Carry placement through import acquisition
|
||||
|
||||
Status: complete. Scope: source-local directives and placed imported applications.
|
||||
|
||||
1. Thread a normalized destination through `loadImportedDocumentsTracked`,
|
||||
`loadImportedWithAccessTracked`, and `importTraversal`, retaining root defaults
|
||||
for current private callers. Place the selected body's application content
|
||||
after `splitImportDirectives`; do the same for every imported body.
|
||||
2. Set placement on selected/imported source records, including error records
|
||||
once a source has been acquired. Keep `ImportedFrom` at the physical directive.
|
||||
Run imported `fragmentVersionClaim` against placed bodies and retain placed
|
||||
bodies in `state.documents` before `composeDocuments` builds applications.
|
||||
3. Leave filesystem joins, containment, glob traversal, optional missing inputs,
|
||||
active cycles, visited identities, and acquisition IDs/order unchanged.
|
||||
Destination must not become part of the repeated-source identity key.
|
||||
4. Test native and host transitive imports, nested source-root directives,
|
||||
import-only bodies, and mounted imported protected versus ordinary version
|
||||
paths. Verify a repeated file at two destinations in one group still fails,
|
||||
including an import/direct-input alias. Reuse cycle/containment regressions.
|
||||
|
||||
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Import|Grouped'`
|
||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Retained imported
|
||||
contributions have effective placement and physical source identity, with no
|
||||
double wrapping or application-level directive leakage.
|
||||
|
||||
## Stage 5 — Complete strict tiers and main version ownership
|
||||
|
||||
Status: complete. Scope: assembly and version interactions, not a new merger.
|
||||
|
||||
1. Review `acquireUnderlayGroup`: version checks must use placed content, and
|
||||
documents taken from `acquired.composition.applications` must already be
|
||||
placed. Recompose strict peers with ordinary `composeDocuments`; preserve
|
||||
per-source applications, origins, UnderlayIndex, and input accounting.
|
||||
2. In `executeLoadRequest`, main's version check uses its placed main/import
|
||||
composition at the existing point. `assembleLayeredInput` recursively overlays
|
||||
completed underlay groups beneath main, selected overlay, and CLI as before.
|
||||
Do not mount the final assembly or prepend main's destination to upper tiers.
|
||||
3. Test disjoint same-destination peers, different destinations, equal and unequal
|
||||
conflicts, ancestor/descendant destinations, and a strict conflict despite an
|
||||
upper override. Verify recursive mapping overlays, atomic sequences, null,
|
||||
explicit false/zero, and version-only domain overrides retaining lower fields.
|
||||
4. Exercise mounted main with a nested VersionRule, absent root declarations,
|
||||
imported/underlay direct and ancestor claims, and nonclaiming mapping ancestors.
|
||||
Check the hook runs once and does not inspect each fragment independently.
|
||||
Preserve exact configured-path integer conversion and ordinary nested typing.
|
||||
|
||||
Validate: destination/composition/overlay/version/grouped tests and everyday
|
||||
check. Validated with `go test ./internal/engine -run 'LayeredDestination'`,
|
||||
`go test ./internal/engine -run 'Destination|Composition|Overlay|Version|Grouped'`,
|
||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The new mounted
|
||||
cases verify strict same-tier conflicts and disjoint composition, independent
|
||||
destinations and nested paths, recursive/atomic overlay behavior, explicit
|
||||
null/false/zero, and main-only nested version checking after imports compose.
|
||||
Done: placed and authored equivalents agree on values and conflicts; main
|
||||
ownership and lifecycle timing are preserved.
|
||||
|
||||
## Stage 6 — Reconcile diagnostic coordinate systems
|
||||
|
||||
Status: complete. Scope: useful paths without fictional source locations.
|
||||
|
||||
1. Audit early acquisition, import, fragment-version, and composition failures.
|
||||
Use one narrow path-prefix translation where diagnostics genuinely identify
|
||||
application content; do not blanket-prefix every diagnostic or double-prefix
|
||||
diagnostics already produced by placed trees.
|
||||
2. Preserve syntax/read/directive locations and related import sites. Value
|
||||
conflicts and later typed/resolver/validation failures use effective paths
|
||||
but original content coordinates. Invalid introduced structure identifies
|
||||
the destination declaration rather than a nonexistent source wrapper line.
|
||||
3. Add focused assertions for a mounted conflict with two real sources, a mounted
|
||||
decode or validation error, an imported version error, invalid destination,
|
||||
and malformed source-local directives. Test stable codes/paths/locations and
|
||||
nil results, not whole messages or callback choreography.
|
||||
|
||||
Validate: destination/diagnostic/import/version/decode tests and everyday check.
|
||||
Validated with `go test ./internal/engine -run 'LayeredDestinationDiagnostics'`,
|
||||
`go test ./internal/engine -run 'Destination|Diagnostic|Import|Version|Decode'`,
|
||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The mounted
|
||||
conflict, decode, and imported-version assertions use effective application
|
||||
paths with authored source locations; invalid destinations have no physical
|
||||
location; malformed source-root directives retain their authored path and
|
||||
location. Later value diagnostics are produced from placed trees, while directive
|
||||
diagnostics retain their raw source coordinates. Post-review remediation also
|
||||
rebases known Mapping value-builder failure paths, which are produced before
|
||||
placement. `TestFacadeDestinationMappingFailurePaths` covers mounted main and
|
||||
underlay failures, escaped keys and sequence-index intent, cycles, unchanged
|
||||
root paths, and unchanged source-constructor failures. Its mounted value cases
|
||||
failed before the fix and pass afterward.
|
||||
Done: diagnostics consistently distinguish application paths from source syntax.
|
||||
|
||||
## Stage 7 — Complete shared placement accounting
|
||||
|
||||
Status: complete. Scope: resource bounds with no new public limit system.
|
||||
|
||||
1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary
|
||||
acquisition and import traversal, using `importAccounting` and current limit
|
||||
structures. Charge each source body once, including empty import bodies.
|
||||
2. Preserve existing raw byte/source/node accounting and imported directive
|
||||
inspection. Additional wrappers count in per-body depth/node checks and
|
||||
shared aggregate nodes; recomposition must not charge them again. Ensure
|
||||
selected overlays and CLI start from totals including placement charges.
|
||||
3. Test below/at/above relevant bounds using small private limit fixtures where
|
||||
existing seams permit; add a narrow private seam if necessary. Cover deep
|
||||
body plus destination, many shallow mounted inputs, imported wrappers, and
|
||||
empty mappings. Verify root placement and NoSource do not add charges.
|
||||
Preserve history/expansion limits; do not turn fixture values into defaults.
|
||||
|
||||
Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'`
|
||||
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in
|
||||
`TestDestinationPlacementCountsAgainstSharedNodeBudget`,
|
||||
`TestMultipleMountedInputsSharePlacementCharges`,
|
||||
`TestImportedEmptyBodiesChargeEachDestinationWrapper`,
|
||||
`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and
|
||||
`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and
|
||||
above the boundary, multiple shallow inputs, imported empty bodies, and charging
|
||||
of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues
|
||||
to cover per-document depth/node boundaries; existing grouped accounting tests
|
||||
cover unchanged raw source/byte charging. Done: placement cannot bypass current
|
||||
budgets and introduces no unrelated caps.
|
||||
|
||||
## Stage 8 — Preserve provenance, history, and source resource contexts
|
||||
|
||||
Status: complete. Scope: engine inspection and retained evidence.
|
||||
|
||||
1. Audit `compositionOrigins`, `completeCompositionOrigins`, application replay,
|
||||
metadata construction/finalization, and source-record cloning against placed
|
||||
applications. Prefer feeding placed trees into existing processing rather
|
||||
than adding another path-offset index or rewriting final metadata afterward.
|
||||
2. Verify explicit destination containers, actual leaves, composite origins,
|
||||
superseded/removed descendants, and root/source fallbacks. Historical and
|
||||
effective indexes keep independently owned paths and existing shape semantics.
|
||||
3. Preserve source-ID resource contexts through imports, surviving lower-tier
|
||||
values, and inheritance. Test completed-result and resolution-time context
|
||||
queries against real relative files, without making ConfigKit open the
|
||||
referenced application resource or retain loading handles.
|
||||
4. Exercise the same physical source in separate tiers with different placement,
|
||||
inspect each source record, and verify independent destination paths. Repeat
|
||||
inspection after comparison and independent loads to detect retained mutation.
|
||||
|
||||
Validated with `go test ./internal/engine -run 'Destination|Metadata|Provenance|History|Resource|SourceContext'`,
|
||||
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
|
||||
linux/amd64); all passed. `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`
|
||||
checks synthetic-container fallback locations, authored leaf coordinates,
|
||||
composite origins, removed-descendant history, and the same host file retained
|
||||
at distinct destinations in separate tiers before and after comparison and an
|
||||
independent load. `TestLayeredImportedInheritedValuesRetainResourceContexts`
|
||||
uses real temporary files to verify resolution-time and completed-result
|
||||
relative-resource lookup through an imported surviving parent and its inherited
|
||||
child. Existing metadata and resource-context tests continue to cover detached
|
||||
records and loading-handle closure. Done: inspection explains effective
|
||||
placement and physical origin without consumer reconstruction.
|
||||
|
||||
## Stage 9 — Verify final inheritance, sensitivity, and lifecycle integration
|
||||
|
||||
Status: complete. Scope: complete private engine behavior before facade exposure.
|
||||
|
||||
1. Resolve sibling inheritance only after full source precedence using the
|
||||
existing expansion path. Cover a child and parent supplied by separate peers,
|
||||
nested destination-local siblings, cancellation, and an inherited relative
|
||||
resource retaining its declaring context. Cross-collection search is unchanged.
|
||||
2. Reuse existing inheritance-version, overlay, and CLI guards; add targeted
|
||||
mounted cases for actual protected paths and their ancestors. Do not reject
|
||||
unrelated inheritance merely because it shares a mapping ancestor.
|
||||
3. Test effective-path secret predicates, field tags and recorded dependencies,
|
||||
superseded classified values, safe effective output/history/explanation, and
|
||||
bilateral diff redaction using disposable canaries. Compare mounted versus
|
||||
explicitly wrapped loads for equal effective values and no differences.
|
||||
4. Verify defaults, decoding, resolution, secret lookup, and validation execute
|
||||
once on the final shape; failures return no usable result. Reuse existing
|
||||
ordinary-load lifetime/ownership coverage and add mounted protection where
|
||||
placement creates a distinct risk.
|
||||
|
||||
Validate: destination/inheritance/secret/sensitivity/explain/diff/lifecycle tests
|
||||
and everyday check. Validated with
|
||||
`go test ./internal/engine -run 'Destination|Inheritance|Secret|Sensitivity|Explain|Diff|Load|Version|Grouped' -count=1`,
|
||||
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
|
||||
linux/amd64); all passed.
|
||||
|
||||
`TestLayeredDestinationInheritanceUsesFinalSiblingScopes` verifies parent and
|
||||
child contributions from separate peers, a higher-tier parent update, nested
|
||||
destination-local siblings, and cancellation. `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`
|
||||
keeps sibling lookup local. `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers`
|
||||
protects inherited values, scalar/null ancestors, consumed directives, selected
|
||||
overlays, and CLI overrides at mounted paths while allowing nonclaiming mapping
|
||||
ancestors and unrelated inheritance. The coverage exposed an ancestor gap in
|
||||
the existing inheritance-version check; it now applies the shared fragment claim
|
||||
logic to the transferred parent subtree.
|
||||
|
||||
`TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape` checks one
|
||||
defaults/resolution/secret-lookup/validation lifecycle, final mounted decoding,
|
||||
field tags and effective-path predicates, superseded history, and safe effective
|
||||
output/explanation against canaries; it compares mounted and explicitly wrapped
|
||||
loads for equal configuration and no diff. `TestLayeredDestinationDiffUsesBilateralSensitivity`
|
||||
checks redaction from either side and recorded dependencies, and
|
||||
`TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` checks a
|
||||
mounted decode failure returns no result. Stage 8's
|
||||
`TestLayeredImportedInheritedValuesRetainResourceContexts` continues to verify
|
||||
the declaring context for inherited relative resources. Done: approved engine
|
||||
semantics integrate without per-fragment loading or a post-return processing
|
||||
pass.
|
||||
|
||||
## Stage 10 — Expose the root-owned facade and public acceptance tests
|
||||
|
||||
Status: complete. Scope: the two public fields and complete consumer behavior.
|
||||
|
||||
1. Add `Input.Destination Path` and `SourceInfo.Destination Path` to root types.
|
||||
Extend `toEngineInput`, `fromEngineSourceInfo`, `toEngineSourceInfo`, and all
|
||||
source publication paths with owned conversion. Run existing public/import
|
||||
boundary tests; expose no engine nodes, aliases, or bridge methods.
|
||||
2. Document zero/root behavior, mapping-only paths, main/underlay parity,
|
||||
inherited import placement, source-coordinate fallback, and source ownership
|
||||
beside declarations. Update the configuration contract for implemented input
|
||||
destinations, preserving canonical ownership of shared semantics.
|
||||
3. Add external-package public-load coverage for equivalent host file, native
|
||||
fs.FS file, byte, reader, and Mapping sources. Cover default/root destinations,
|
||||
literal escaped/numeric keys, empty mapping, NoSource, and invalid declarations.
|
||||
Include one public mounted main case, not just underlays.
|
||||
4. Add representative public tests tying final configuration to metadata,
|
||||
source placement, history, resources, callbacks, and safe diffs. Mutate returned
|
||||
record copies and repeat queries/loads to confirm detachment. Reuse engine
|
||||
matrices; public tests demonstrate supported integration rather than every
|
||||
private branch. Confirm failed loads expose detached source destinations.
|
||||
|
||||
Validated with `go test . -run 'Destination|FacadeLayered|PublicFacadeBoundary|DependencyImportBoundary' -count=1`,
|
||||
`go test -race ./...`, and `./scripts/check.sh` (go1.26.8 linux/amd64); all
|
||||
passed.
|
||||
|
||||
`TestFacadeInputDestinationsAcrossSourceKinds` exercises host, native
|
||||
filesystem, bytes, reader, and mapping inputs with literal escaped and
|
||||
numeric-looking mapping keys. `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths`
|
||||
covers omitted/empty root placement, empty mapping presence, absent `NoSource`,
|
||||
invalid index/reserved destinations, and reader preflight. `TestFacadeImportedSourcesRetainInputDestination`
|
||||
checks public transitive import placement. `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff`
|
||||
ties mounted main/underlay values to metadata, both source destinations, callback
|
||||
source context, relative-resource access during and after loading, history, safe
|
||||
effective/explanation rendering, and bilateral diff redaction; it also mutates
|
||||
returned records and confirms later queries and comparisons remain unchanged.
|
||||
`TestFacadeFailedLoadExposesDetachedSourceDestination` verifies failure records,
|
||||
and `TestSourceInfoDestinationConversionsAndCopies` covers root/engine source
|
||||
record conversion. Existing public-boundary tests pass with the new root-owned
|
||||
fields. Done: destination selection and inspection are available through the
|
||||
supported ConfigKit import path.
|
||||
|
||||
## Stage 11 — Add maintained simple and collection-style consumer examples
|
||||
|
||||
Status: complete. Scope: executable adoption examples with consumer-owned types.
|
||||
|
||||
1. Add a minimal mounted-fragment example using `Input.Destination` in an
|
||||
ordinary layered load. Show literal keys and root-default compatibility
|
||||
without introducing consumer parsing or generic merge helpers.
|
||||
2. Add one representative collection assembly under `examples/consumers`: peer
|
||||
prompt/profile fragments, collection metadata with ordinary `version`, a
|
||||
source-root import, local sibling inheritance, and an intentional higher-tier
|
||||
version-only override retaining the body. Keep the application VersionRule
|
||||
main-owned. Use a controlled native filesystem or temporary files.
|
||||
3. Demonstrate effective-path inspection plus source-relative resource lookup
|
||||
during resolution and from the result, including an inherited/surviving field.
|
||||
Application code owns opening/interpreting the resource and any qualified IDs.
|
||||
4. Add tests for final values, presence, actual source placement/coordinates,
|
||||
resource context, and expected conflict behavior. Link maintained examples
|
||||
from the consumer guide; add a small executable root example if useful for
|
||||
discovery without duplicating the complete scenario.
|
||||
|
||||
Validated with `go test ./examples/consumers -count=1`,
|
||||
`go test . -run '^Example' -count=1`, and `./scripts/check.sh` (go1.26.8
|
||||
linux/amd64); all passed, including local documentation links.
|
||||
|
||||
`Example_inputDestination` demonstrates a literal mounted mapping path and an
|
||||
ordinary root input without consumer-side parsing or merging.
|
||||
`LoadCollectionBundle` and `TestCollectionBundleAssemblesMountedPeersAndResources`
|
||||
exercise prompt/profile peer sources, imported collection metadata with an
|
||||
ordinary version label, main-owned schema-version checking, local sibling
|
||||
inheritance, and a higher-tier prompt version-only change that retains the rest
|
||||
of its body. The resolver and `ReadCollectionPromptContent` follow direct and
|
||||
inherited `content_file` provenance to the declaring native-filesystem context.
|
||||
The test checks final values, explicit presence, source destinations and authored
|
||||
coordinates, relative resources, and the inherited field's origin.
|
||||
`TestCollectionPeerPromptConflictsRemainStrict` preserves expected conflict
|
||||
behavior. The examples are linked from the consumer adoption guide. Done: simple
|
||||
and representative consumers substantiate the same model without a compensating
|
||||
structural pass.
|
||||
|
||||
## Stage 12 — Reconcile documentation and audit the complete implementation
|
||||
|
||||
Status: complete. Scope: final integration audit and evidence, not release work.
|
||||
|
||||
1. Update README navigation, consumer guidance, the component overview, and
|
||||
development reading/test maps. Add ADR 0011 to navigation. Update architecture
|
||||
policy only as needed to recognize placement within existing assembly and
|
||||
source ownership, linking precise behavior to the configuration contract.
|
||||
Do not change unrelated policies, deferred boundaries, or published notes.
|
||||
2. Audit every resolved decision, roadmap behavior, and request acceptance row
|
||||
against implementation and tests. Record the corresponding test names here;
|
||||
for reused coverage explain the behavior it proves. Do not mark complete
|
||||
based on green tests that do not exercise mounting.
|
||||
3. Review the complete baseline-to-candidate diff for accidental strengthening:
|
||||
extra naming rules, namespace uniqueness, main-only restrictions, changed
|
||||
merge/version semantics, invented source-read ordering guarantees, new limits,
|
||||
or expanded secret policies. Remove temporary helpers/unsupported branches
|
||||
left by staged integration. Confirm original source coordinates remain real.
|
||||
4. Run `./scripts/check.sh` and
|
||||
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...`. Review source-kind and
|
||||
placement ownership coverage, error disclosure, complete-on-return handling,
|
||||
and root/private boundaries. Record commit, Go version, results, and any
|
||||
limitations. No release command, CI waiting, benchmark, or downstream upgrade.
|
||||
5. Mark this plan and the roadmap implemented only when code, public examples,
|
||||
and owning contracts agree. Retain completed planning evidence until separately
|
||||
authorized release preparation.
|
||||
|
||||
Audit evidence for the approved decisions and request acceptance surface:
|
||||
|
||||
| Behavior reviewed | Evidence and what it proves |
|
||||
| --- | --- |
|
||||
| Root-owned `Input.Destination` and `SourceInfo.Destination`; detached conversions and publication | `TestSourceInfoDestinationConversionsAndCopies`, `TestFacadeFailedLoadExposesDetachedSourceDestination`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` verify root/engine conversion, error records, and returned record detachment. `TestPublicFacadeBoundary` and `TestDependencyImportBoundary` verify no engine types cross the facade. |
|
||||
| Literal mapping keys, parsed numeric keys, reserved structural tokens, and invalid-path preflight | `TestNormalizeDestinationUsesLiteralMappingKeys`, `TestNormalizeDestinationRejectsIndicesAndReservedKeys`, and `TestLayeredLoadPreflightsDestinationsBeforeReadingSources` cover mapping-only normalization and rejection before reader consumption; `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths` verifies the public error boundary. |
|
||||
| Placement of acquired bodies, empty mappings versus absence, imports, all source kinds, physical identity, and authored coordinates | `TestFacadeInputDestinationsAcrossSourceKinds` covers host/native/bytes/reader/mapping sources; `TestFacadeSameFragmentMountsAtSeparateCollectionDestinations` proves independent public collections can retain the same local key. `TestLayeredImportOnlyInputMaterializesDestination`, `TestLayeredHostImportsInheritInputDestinationTransitively`, `TestLayeredNativeFilesystemImportsInheritInputDestination`, and `TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity` cover inherited import placement and unchanged identity. `TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates`, `TestPlaceDocumentPreservesAuthoredEntryCoordinates`, and `TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations` distinguish fallback wrapper locations from authored coordinates. |
|
||||
| Strict composition, explicit-wrapper equivalence, precedence, empty values, and domain `version` overrides | `TestLayeredDestinationStrictCompositionBoundaries`, `TestLayeredDestinationMatchesExplicitlyWrappedInput`, `TestEmptyMountedMappingRetainsLowerChildren`, and `TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics` prove existing group and overlay semantics are retained; the latter includes ordinary field-only updates rather than whole-record replacement. |
|
||||
| Main-owned version checks and imported, underlay, inheritance, overlay, CLI, and ancestor protection | `TestLayeredDestinationChecksVersionOnceOnComposedMain`, `TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths`, `TestLayeredImportedVersionClaimsUseEffectiveDestination`, and `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers` cover effective paths, one main check, protected-path claims/ancestors, and nonclaiming mapping ancestors. `TestLoadVersionDestinationTypesKeepOrdinaryDecoding` preserves exact configured-path conversion and ordinary nested decoding. |
|
||||
| Final-shape sibling inheritance, no cross-collection lookup, one lifecycle, and failure without a usable partial result | `TestLayeredDestinationInheritanceUsesFinalSiblingScopes`, `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, and `TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` exercise these behaviors. Reused inheritance, lifecycle, and callback tests cover generic behavior that destination placement does not alter. |
|
||||
| Effective paths, presence, provenance/history, safe rendering, sensitivity, bilateral diff, and source-relative resources | `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, `TestLayeredDestinationDiffUsesBilateralSensitivity`, `TestLayeredImportedInheritedValuesRetainResourceContexts`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` connect mounted values to inspection. The imported-inheritance test specifically proves the inherited value keeps the declaring source's passive resource context. |
|
||||
| Shared depth/node limits and unchanged root/absence accounting | `TestPlaceDocumentRootAndBounds`, `TestDestinationPlacementCountsAgainstSharedNodeBudget`, `TestMultipleMountedInputsSharePlacementCharges`, `TestImportedEmptyBodiesChargeEachDestinationWrapper`, `TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and `TestNoSourceAndRootPlacementAddNoWrapperCharges` cover per-document and aggregate limits without adding a new limit class. |
|
||||
| Maintained consumer usage and strict peer behavior | `Example_inputDestination`, `TestCollectionBundleAssemblesMountedPeersAndResources`, and `TestCollectionPeerPromptConflictsRemainStrict` verify the simple and representative collection examples against actual values, resources, source records, and conflict behavior. |
|
||||
|
||||
The wider baseline-to-candidate review covered all 38 changed files (`6353598..2d8df22`): production changes are limited to the root facade fields/conversions, private input/import assembly and accounting, source record ownership, and the inheritance version-claim correction described above. The contract and tests do not impose identifier grammar, namespace uniqueness, main-only placement, source-read ordering, new budgets, nested version conversion, or expanded secret policy. The implementation wraps only acquired application bodies after processing source-root directives; placement does not change physical source identity, authored node locations, filesystem resolution, or later-tier semantics. Stage evidence above reuses existing general composition, resource, inheritance, version, lifecycle, sensitivity, and boundary tests where those behaviors are unchanged; mounted-specific cases are named separately.
|
||||
|
||||
Completion record: audited source candidate `2d8df22dd1cb9fde8e60853da3e2260731acf2bb`. On `go1.26.8 linux/amd64`, `./scripts/check.sh` passed, including formatting, whitespace, documentation links, tests, vet, and build. `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` also passed with dependency downloads disabled. No remaining implementation limitations were found; release preparation, CI, benchmark campaigns, and downstream upgrades remain separately scoped.
|
||||
|
||||
Done: one complete layered load supports input destinations with coherent values,
|
||||
diagnostics, inspection, limits, and source ownership across the approved surface.
|
||||
|
||||
## Implementation owner map
|
||||
|
||||
Use graph tools to locate these symbols rather than assume line numbers:
|
||||
|
||||
- Public input forwarding: `layered.go`, `records.go`, `conversion.go`
|
||||
(source conversions), and existing facade tests.
|
||||
- Private request and lifecycle: `internal/engine/layered.go`, `load_request.go`
|
||||
(`loadInput`, `acquireUnderlayGroup`, `assembleLayeredInput`), `load.go`
|
||||
(`executeLoadRequest`), and `load_input.go` (`assembleBaseInputTracked`).
|
||||
- Directives and imports: `directives.go` (`splitImportDirectives`,
|
||||
`fragmentVersionClaim`), and `imports.go` (`visitPath`).
|
||||
- Shared document semantics: `compose.go`, `document_walk.go`
|
||||
(`countInputNodes`), `path.go`, `parse.go`, and `value_operation.go` limits.
|
||||
- Inspection: `metadata.go`, `history.go`, `explain.go`, `resource_context.go`,
|
||||
`inheritance_origin.go`, `render.go`, and `diff.go`; source copies live in
|
||||
engine `records.go`. Confirm current filenames through the component overview.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None required for implementation. The four policy choices are approved; this
|
||||
plan selects API spelling, validation timing/error category, coordinate handling,
|
||||
and accounting within them. Surface a newly discovered incompatibility or scope
|
||||
change with evidence rather than resolving it by silently narrowing the feature
|
||||
or strengthening an unrelated contract.
|
||||
171
docs/roadmap/input-destinations.md
Normal file
171
docs/roadmap/input-destinations.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Configuration Input Destinations Roadmap
|
||||
|
||||
Status: implemented. This roadmap records the approved behavior and rationale
|
||||
for the [PromptKit feature request](promptkit-feature-request.md). The
|
||||
[implementation plan](implementation.md) records the completed sequence and
|
||||
audit evidence; the [configuration contract](../config.md#input-destinations)
|
||||
owns current behavior.
|
||||
|
||||
## Targeted end state
|
||||
|
||||
A consumer can assign a mapping destination to a configuration input and perform
|
||||
one ordinary `LoadLayered` call. The input's application content contributes
|
||||
beneath that destination while retaining normal composition, precedence,
|
||||
inheritance, typed loading, diagnostics, and inspection. PromptKit can assemble
|
||||
collections without parsing and rewriting sources, merging values, or rebuilding
|
||||
provenance itself.
|
||||
|
||||
For example, a fragment defining `summarize.alerts` can contribute beneath
|
||||
`collections`, `weatherreporter`, `prompts`. An independent source with the same
|
||||
local key can contribute beneath another collection without conflict. Collection
|
||||
and prompt names remain ordinary application keys, not ConfigKit namespaces.
|
||||
|
||||
The public surface adds an optional `Destination Path` field on `Input`, not a
|
||||
new loading entry point or an operation on completed results. Content-bearing
|
||||
main inputs and underlay inputs support
|
||||
the same destination model; root placement remains the default.
|
||||
|
||||
The following behavior defines the target:
|
||||
|
||||
- Omitted and empty destinations retain root placement. Destinations consist of
|
||||
literal mapping keys, including dots, slashes, and numeric-looking text.
|
||||
Explicit sequence-index destinations are outside the requested capability.
|
||||
- A valid destination on `NoSource` contributes nothing. An actual empty
|
||||
application mapping materializes its destination mappings as explicit input,
|
||||
matching authored wrappers, without clearing lower-tier children.
|
||||
- Existing structural reservations apply to introduced mappings. Destinations
|
||||
that materialize `configkit` or a mapping-valued `extends` contrary to existing
|
||||
contracts fail through the structured loading-error boundary. Keys merely
|
||||
containing those words remain ordinary keys.
|
||||
- Host files, native filesystem files, bytes, readers, and mapping sources all
|
||||
support placement under their existing acquisition and ownership contracts.
|
||||
Filesystem context, import opt-in, and precedence remain independent choices.
|
||||
- Mounted contributions have the same application values and conflict behavior
|
||||
as explicitly wrapped contributions, with accurate original source locations.
|
||||
Disjoint mappings combine; overlapping non-mapping values within a group
|
||||
conflict even when equal. A later tier cannot repair a strict-group conflict.
|
||||
- Different destinations can coexist in one group, and the same destination can
|
||||
occur in different tiers. Ancestor/descendant destinations follow ordinary
|
||||
composition and overlay rules without namespace uniqueness restrictions.
|
||||
Later tiers recursively overlay mappings and replace other values. Empty
|
||||
mappings do not clear lower values; sequences remain atomic.
|
||||
- A prompt's ordinary `version` override changes that field and retains omitted
|
||||
lower fields. It neither identifies a merge record nor replaces a whole prompt.
|
||||
- Source-root `configkit.imports` remains structural. Imported application data
|
||||
inherits the input destination transitively. Import paths still resolve from
|
||||
their declaring source; containment, optionality, cycles, and repeated-source
|
||||
rules remain applicable. Placement does not change physical source identity.
|
||||
- Sibling inheritance runs after assembly in the effective destination scope.
|
||||
Peer fragments can supply a child and its sibling parent. Qualified PromptKit
|
||||
references do not acquire cross-collection inheritance meaning.
|
||||
- Version ownership is enforced at effective application paths. A collection's
|
||||
mounted `version` is ordinary data, while underlay/import contributions to the
|
||||
actual protected path, or replacements of its ancestors, remain invalid.
|
||||
There is one main-owned version check and no blanket nested numeric conversion.
|
||||
- Defaults, decoding, resolution, secret lookup, and validation run once on the
|
||||
assembled application, not independently on fragments. Callbacks and value
|
||||
diagnostics use effective paths. Failures use structured load errors and yield
|
||||
no usable partial result.
|
||||
- Presence, provenance, history, explanations, effective rendering, and diffs
|
||||
agree with the final values. Sensitivity is evaluated at effective application
|
||||
paths under the existing historical and dependency rules.
|
||||
- Existing source records expose placement, including inherited import
|
||||
placement. Introduced ancestor mappings retain the contributing source's
|
||||
identity; any root-location fallback identifies that document, not an authored
|
||||
wrapper. Actual content retains its real source coordinates.
|
||||
- Original source locations and passive resource contexts survive placement,
|
||||
overrides, and inheritance. `Result.SourceContext` and
|
||||
`Resolution.SourceContext` still identify the declaring filesystem and base
|
||||
directory for a field such as `content_file: ./summary.md`.
|
||||
- Destinations and the structure they introduce participate in existing load
|
||||
limits; placement cannot bypass depth or node accounting. Source ownership,
|
||||
complete-on-return loading, and detached result behavior remain intact.
|
||||
|
||||
Import-destination syntax, independent collection specification headers, and
|
||||
cross-collection inheritance are separate possible follow-ups. This work does
|
||||
not add directory discovery, collection installation, remote acquisition,
|
||||
namespace ownership enforcement, dependency solving, live reload, filename-based
|
||||
identity, arbitrary transformations, or new merge strategies. PromptKit owns its
|
||||
collection inventory, qualified IDs, and execution semantics.
|
||||
|
||||
## Implementation baseline (v0.7.1)
|
||||
|
||||
The request reviewed v0.7.1. At that baseline, the relevant boundaries were:
|
||||
|
||||
- [Input](../../layered.go) holds a source, import opt-in, and filesystem context,
|
||||
but no destination. Root types delegate to the engine through the existing
|
||||
[facade boundary](../internal/overview.md).
|
||||
- [Layered loading](../config.md#layered-loading) already provides strict groups,
|
||||
ordered precedence, shared acquisition budgets, and one final lifecycle.
|
||||
[Underlay acquisition](../../internal/engine/load_request.go) gathers source
|
||||
applications and recomposes them into their strict group.
|
||||
- [Input acquisition](../../internal/engine/load_input.go) and
|
||||
[import traversal](../../internal/engine/imports.go) process source-root
|
||||
directives and source identity before final assembly. Imported version claims
|
||||
are checked during traversal; underlay claims are checked before group
|
||||
composition. These currently operate without a destination.
|
||||
- [Version claim checks](../../internal/engine/directives.go) inspect a document
|
||||
using the configured application path. Wrapping only an already acquired
|
||||
result would leave early fragment checks and diagnostic paths incorrect.
|
||||
- [Sibling inheritance](../adr/0009-sibling-inheritance.md) already runs after
|
||||
source precedence and preserves source-to-destination derivation. Placement
|
||||
must feed that existing mechanism rather than resolve fragments independently.
|
||||
- Existing source records identify acquisition and precedence but do not expose
|
||||
input placement. Existing history retains source applications, so moving only
|
||||
the final tree would be insufficient for coherent inspection.
|
||||
|
||||
The architectural fit is an extension of input assembly, not a parser extension
|
||||
or a second configuration model. The
|
||||
[architecture policy](../policy/architecture.md) supplies the ownership and
|
||||
consistency constraints; [deferred scope](deferred.md) continues to govern the
|
||||
unrequested features above.
|
||||
|
||||
## Selected Design Decisions
|
||||
|
||||
### Main-input destinations and absent inputs
|
||||
|
||||
Allow the same destination model for content-bearing main inputs and underlays,
|
||||
with `VersionRule.Path` remaining an effective application path and any
|
||||
specification declaration still owned by main. The checker retains its existing
|
||||
handling of an absent declaration and its existing lifecycle phase. `NoSource`
|
||||
contributes nothing even with a valid destination. This keeps `Input` coherent
|
||||
across its roles and preserves absence without per-fragment version checks.
|
||||
|
||||
### Public placement inspection and synthetic ancestors
|
||||
|
||||
Expose effective placement as `SourceInfo.Destination Path` on existing source
|
||||
records, including inherited import placement.
|
||||
Use original source identity for introduced ancestor mappings, documenting that
|
||||
any root-location fallback identifies the contributing document rather than a
|
||||
physically authored wrapper. Actual content keeps its real coordinates. This
|
||||
allows consumers to connect placement to sources without a new query system.
|
||||
|
||||
Preserve that relationship through inherited fields, composite origins, and the
|
||||
same source selected in separate tiers. Placement is per source application;
|
||||
consumers can inspect it without inferring it from final paths.
|
||||
|
||||
### Reserved structural keys in destinations
|
||||
|
||||
Use existing `Path` conventions for literal keys and parsed
|
||||
numeric tokens, reject explicit index segments, and preserve current structural
|
||||
reservations in the effective tree. Reject destination segments that would
|
||||
materialize `configkit` or a mapping-valued `extends` contrary to those contracts,
|
||||
with a clear structured destination error. Ordinary keys containing those words,
|
||||
dots, or slashes remain ordinary keys. Source-root directives are processed before
|
||||
placement and are not rejected simply because their application body is mounted.
|
||||
|
||||
Mounted and authored wrappers therefore retain the same structural meaning.
|
||||
Destination diagnostics identify placement as the cause of invalid introduced
|
||||
structure, using existing error categories where suitable.
|
||||
|
||||
### Empty mappings and presence of introduced containers
|
||||
|
||||
Mounting an actual empty application mapping materializes its
|
||||
destination mappings as explicit contributions, matching authored wrappers.
|
||||
This preserves the request's equivalence principle and lets consumers inspect
|
||||
an intentionally empty collection. Such mappings still do not clear lower-tier
|
||||
children. An absent source contributes nothing.
|
||||
|
||||
This applies equally to empty imported application bodies and inputs containing
|
||||
only import directives. Presence follows the contribution's meaning rather than
|
||||
whether an implementation happened to allocate wrapper nodes.
|
||||
319
docs/roadmap/promptkit-feature-request.md
Normal file
319
docs/roadmap/promptkit-feature-request.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# PromptKit Feature Request: Configuration Input Destinations
|
||||
|
||||
Status: consumer feature request; not accepted or implemented.
|
||||
|
||||
## Requested Capability
|
||||
|
||||
Provide a public API through which a consumer can select a destination mapping
|
||||
path for each configuration input. The input's application content contributes
|
||||
beneath that path while retaining ConfigKit's normal composition, precedence,
|
||||
inheritance, validation, diagnostics, and inspection behavior.
|
||||
|
||||
PromptKit needs this capability for its proposal 3, **Collections With Mounted
|
||||
Configuration Fragments**, recorded in PromptKit's
|
||||
`docs/roadmap/configuration-collections-mounted-fragments.md`. This request
|
||||
describes the public API needs and observable acceptance criteria. It does not
|
||||
prescribe ConfigKit's internal design or an implementation plan.
|
||||
|
||||
The reviewed public baseline is ConfigKit v0.7.1. The current
|
||||
[layered-loading contract](../config.md#layered-loading) lets consumers choose
|
||||
sources, import policy, filesystem context, and precedence groups, but does not
|
||||
provide an input destination. Existing behavior remains authoritative until an
|
||||
extension is adopted.
|
||||
|
||||
## Consumer Problem
|
||||
|
||||
PromptKit applications assemble independently maintained collections of prompts,
|
||||
inference profiles, and backends. Collections can contain identical local names,
|
||||
such as `summarize.alerts`, without identifying the same resource. PromptKit wants
|
||||
an effective configuration organized by collection, while letting authors omit
|
||||
repeated collection and resource-container wrappers from individual files.
|
||||
|
||||
A file selected from a collection's `prompts` directory would contain:
|
||||
|
||||
```yaml
|
||||
summarize.alerts:
|
||||
version: "1"
|
||||
default_profile: provider/brief
|
||||
messages:
|
||||
- role: user
|
||||
content: Summarize the weather alerts.
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
```
|
||||
|
||||
Given destination `[collections, weatherreporter, prompts]`, its contribution
|
||||
would appear in the final application configuration as:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
weatherreporter:
|
||||
prompts:
|
||||
summarize.alerts:
|
||||
version: "1"
|
||||
default_profile: provider/brief
|
||||
messages:
|
||||
- role: user
|
||||
content: Summarize the weather alerts.
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
```
|
||||
|
||||
Another collection can contribute `summarize.alerts` beneath
|
||||
`[collections, homewatch, prompts]` without conflicting. After final handoff,
|
||||
PromptKit interprets these as `weatherreporter/summarize.alerts` and
|
||||
`homewatch/summarize.alerts`. The profile reference is illustrative and must be
|
||||
supplied separately by the application configuration.
|
||||
|
||||
PromptKit will support one installed definition per prompt. Its optional flat
|
||||
`version` is ordinary domain metadata, not a merge identity. This request does
|
||||
not need identified-record sequences, sequence-root documents, or a custom
|
||||
merge policy.
|
||||
|
||||
Authors could write the complete wrappers today, but that repeats assembly
|
||||
details in every fragment. Consumer-side parsing and wrapping would put generic
|
||||
source processing back into PromptKit and complicate source attribution.
|
||||
PromptKit instead wants to select sources and destinations, then receive one
|
||||
final configuration through ConfigKit's supported loading lifecycle.
|
||||
|
||||
## Minimum Public API
|
||||
|
||||
### Per-Input Destination
|
||||
|
||||
Allow a destination to be attached to each `Input` passed to `LoadLayered`.
|
||||
One possible public spelling is an optional `Destination Path` field on `Input`.
|
||||
The name and exact signature are suggestions; equivalent public API designs
|
||||
are welcome.
|
||||
|
||||
Illustrative consumer code using the proposed field, which does not exist in
|
||||
v0.7.1:
|
||||
|
||||
```go
|
||||
promptInput := configkit.Input{
|
||||
Source: configkit.File("/usr/local/share/collections/weatherreporter/prompts/alerts.yml"),
|
||||
Destination: configkit.Keys("collections", "weatherreporter", "prompts"),
|
||||
}
|
||||
|
||||
peers := configkit.Underlay{
|
||||
Inputs: []configkit.Input{promptInput},
|
||||
}
|
||||
```
|
||||
|
||||
The consumer would include `peers` in its existing `LayeredOptions` and invoke
|
||||
`LoadLayered` with its normal root main input and application `Definition`.
|
||||
This is a partial API sketch, not an executable example or a proposed new
|
||||
loading entry point. A single public layered load is sufficient for PromptKit;
|
||||
it does not require a separate mounting operation on completed results.
|
||||
|
||||
The API must let the consumer:
|
||||
|
||||
- Express a destination relative to the application configuration root using
|
||||
literal mapping-key segments. Dots, slashes, and numeric-looking text inside
|
||||
a key are not implicit path separators or array indexes. PromptKit's namespace
|
||||
grammar remains its own domain rule.
|
||||
- Omit the destination to retain existing root placement. An explicitly empty
|
||||
destination should have the same meaning. Mapping destinations are sufficient;
|
||||
sequence-index destinations are not needed by this request.
|
||||
- Assign different destinations to inputs in the same strict composition group,
|
||||
and assign the same destination to inputs in separate precedence groups.
|
||||
- Use the destination with existing content-bearing source kinds: host files,
|
||||
native filesystem files, bytes, readers, and mapping sources, subject to their
|
||||
existing acquisition and ownership contracts.
|
||||
- Retain the existing import opt-in and filesystem context independently of the
|
||||
destination. A configuration destination is not a filesystem root or base
|
||||
directory.
|
||||
|
||||
PromptKit can continue to enumerate an explicitly selected directory and attach
|
||||
the same destination to its returned inputs. ConfigKit does not need a new
|
||||
directory scanner, namespace registry, filename convention, or PromptKit schema.
|
||||
The namespace comes from the application's assembly declaration, not from an
|
||||
implicit interpretation of a directory basename.
|
||||
|
||||
### Representative Assembly
|
||||
|
||||
These are application choices, not ConfigKit-specific reserved paths:
|
||||
|
||||
| Selected inputs | Destination key segments |
|
||||
| --- | --- |
|
||||
| Application main document | Root |
|
||||
| `weatherreporter/collection.yml` | `collections`, `weatherreporter` |
|
||||
| YAML files under `weatherreporter/prompts` | `collections`, `weatherreporter`, `prompts` |
|
||||
| YAML files under `homewatch/prompts` | `collections`, `homewatch`, `prompts` |
|
||||
| YAML files under `provider/profiles` | `collections`, `provider`, `profiles` |
|
||||
| YAML files under `provider/backends` | `collections`, `provider`, `backends` |
|
||||
|
||||
Peer collection contributions belong to a single strict underlay group when the
|
||||
application wants conflict detection among them. Intentional operator overrides
|
||||
use later groups or main. Destination selection must not establish precedence
|
||||
or make source traversal order authorize replacement.
|
||||
|
||||
## Required Observable Behavior
|
||||
|
||||
### Composition And Precedence
|
||||
|
||||
Mounted and explicitly wrapped contributions must have equivalent application
|
||||
values and conflict behavior at their effective destinations. Actual source
|
||||
locations naturally differ and must remain accurate.
|
||||
|
||||
Retain the [strict composition](../config.md#strict-document-composition) and
|
||||
[layering](../config.md#layered-loading) rules:
|
||||
|
||||
- Disjoint mapping entries at the same destination combine.
|
||||
- Overlapping non-mapping values within a strict group conflict, including equal
|
||||
scalars, nulls, and sequences. Different destinations do not collide merely
|
||||
because the source documents share a local key.
|
||||
- Later precedence layers recursively override mappings and replace other
|
||||
values. A strict conflict cannot be repaired by an upper layer.
|
||||
- Empty mappings do not clear lower definitions; ordinary arrays remain atomic.
|
||||
In particular, `messages` does not acquire sequence-merging behavior.
|
||||
- Overlapping destination paths and contributions at their ancestors follow
|
||||
those same rules. No namespace-level uniqueness restriction is requested.
|
||||
|
||||
Changing only a prompt's `version` in a later layer changes that field while
|
||||
retaining omitted lower-layer fields. ConfigKit must not treat a domain version
|
||||
label as a request to replace an entire definition.
|
||||
|
||||
### Imports And Existing Structural Directives
|
||||
|
||||
An input with imports enabled must be able to use its existing source-root
|
||||
`configkit.imports` directive while contributing application data at a nested
|
||||
destination. The directive remains structural configuration, not an application
|
||||
field below that destination.
|
||||
|
||||
For the minimum API, imports inherit their importing input's destination,
|
||||
including transitive imports. Import paths continue to resolve relative to
|
||||
their declaring source's filesystem context. A mounted document must not send
|
||||
its imported application fields back to the configuration root accidentally.
|
||||
|
||||
Existing opt-in, containment, missing-input, cycle, and repeated-source behavior
|
||||
must remain applicable. A different destination is not a request to bypass the
|
||||
current rejection of a repeated physical source within a strict group.
|
||||
|
||||
Sibling `extends` must resolve against mappings at their effective destinations.
|
||||
For example, two independently supplied profile fragments mounted beneath the
|
||||
same collection's `profiles` mapping can establish `extends: brief` once they
|
||||
compose. Inheritance remains ConfigKit-owned; PromptKit does not perform another
|
||||
relationship-resolution pass.
|
||||
|
||||
### Protected Specification Paths
|
||||
|
||||
The distinction between source-local field names and effective application
|
||||
paths is essential. PromptKit's main document will declare `version: 1` at the
|
||||
configuration root. A separate collection metadata file can contain:
|
||||
|
||||
```yaml
|
||||
version: "2.4.0"
|
||||
```
|
||||
|
||||
Mounted at `[collections, weatherreporter]`, that field is an ordinary collection
|
||||
release label. It must not be rejected as an attempt to contribute the protected
|
||||
root specification field. The same applies to a prompt's nested optional
|
||||
version. Only the main-owned specification declaration uses the application's
|
||||
configured [VersionRule](../config.md#application-schema-versions).
|
||||
|
||||
Conversely, placement must not permit an underlay or imported fragment to claim
|
||||
or replace the actual protected specification path or an ancestor that would
|
||||
replace it. The public contract must define protection in terms of effective
|
||||
application paths, rather than reserving the spelling `version` in every source.
|
||||
Protection must remain valid with imports, inheritance, overlays, and explicit
|
||||
CLI overrides.
|
||||
|
||||
PromptKit needs one main-owned specification check with the existing lifecycle.
|
||||
It does not need main mounted away from the root, integer-to-string conversion
|
||||
for every nested `version`, or separate compatibility checks for every fragment.
|
||||
|
||||
### Typed Loading, Diagnostics, And Inspection
|
||||
|
||||
The destination must be visible consistently through the ordinary public
|
||||
loading and inspection surface:
|
||||
|
||||
- Final typed configuration and application callbacks use effective destination
|
||||
paths. Defaults, resolution, and validation retain the existing single-load
|
||||
lifecycle; fragments are not independently validated as whole applications.
|
||||
- Metadata and presence queries distinguish omission, explicit null, and other
|
||||
explicit values at the mounted paths.
|
||||
- Diagnostics identify the effective conflicting or invalid path and the real
|
||||
contributing source locations. Consumers can determine which source and
|
||||
placement produced a value without recreating that relationship themselves.
|
||||
- Effective rendering, explanations, history, and configuration comparison agree
|
||||
with the final configuration. Existing sensitivity and disclosure rules apply
|
||||
to mounted fields, including application path-based secret classification.
|
||||
- Unsupported destination forms and destination-related loading failures use
|
||||
the normal structured error boundary. Exact new diagnostic codes, if needed,
|
||||
are for ConfigKit to define.
|
||||
|
||||
PromptKit does not require a separate mount registry or new inspection facade.
|
||||
Existing inspection APIs are sufficient if they expose accurate effective paths,
|
||||
source attribution, and placement information. ConfigKit should document how
|
||||
source-local locations relate to effective paths.
|
||||
|
||||
### Source-Relative Resources And Lifecycle
|
||||
|
||||
For a mounted field such as `content_file: ./summary.md`, obtaining its effective
|
||||
origin and calling `Result.SourceContext` or `Resolution.SourceContext` must
|
||||
still identify the declaring source's filesystem and base directory. The same
|
||||
holds when that field survives an override or is inherited into another mapping.
|
||||
Its configuration destination must not change the meaning of the relative file
|
||||
reference.
|
||||
|
||||
Retain existing source ownership, load completion, result lifetime, containment,
|
||||
and resource-access behavior. Destinations must participate in documented load
|
||||
limits without creating a way to bypass them; this request does not prescribe
|
||||
new limit values or their accounting machinery.
|
||||
|
||||
## Optional Follow-Ups, Not Prerequisites
|
||||
|
||||
**Import destinations in YAML.** A destination on an import directive could let
|
||||
publishers describe assembly in configuration rather than Go. Proposal 3
|
||||
recommends destinations relative to the importing document's destination, with
|
||||
omission retaining that destination. This is useful, but per-input placement
|
||||
through the public API is sufficient for initial PromptKit adoption. No new
|
||||
directive spelling is selected by this request.
|
||||
|
||||
**Independent specification headers.** A collection entry document might later
|
||||
declare its own supported specification version. That needs a distinct contract
|
||||
for checking the header and determining its role in the composed configuration.
|
||||
Mounting collection release metadata does not supply this capability. Keep it a
|
||||
separate request if PromptKit decides it is necessary.
|
||||
|
||||
**Cross-collection inheritance.** Nested collection profiles are not immediate
|
||||
siblings across collections. Current sibling inheritance remains sufficient for
|
||||
the initial proposal, with authored definitions flattened where necessary.
|
||||
Generic cross-path inheritance could be considered separately if consumer needs
|
||||
justify revisiting the [deferred scope](deferred.md). Mounting must not silently
|
||||
reinterpret `extends: provider/brief` as a cross-collection reference.
|
||||
|
||||
PromptKit also does not request collection installation, automatic directory
|
||||
discovery, remote fetching, namespace ownership enforcement, dependency solving,
|
||||
live reload, filename-derived identity, or arbitrary document transformations.
|
||||
Its qualified resource IDs, optional prompt version semantics, collection
|
||||
inventory, and execution APIs remain PromptKit responsibilities.
|
||||
|
||||
## Public Acceptance Scenarios
|
||||
|
||||
Evaluate the extension through supported public loading and inspection APIs:
|
||||
|
||||
| Scenario | Expected consumer-visible result |
|
||||
| --- | --- |
|
||||
| Destination omitted or empty | Existing root-placement behavior |
|
||||
| Same fragment shape mounted under two distinct collection keys from independent sources | Two separate effective resources |
|
||||
| Disjoint inputs at the same destination in one group | Mapping contributions combine |
|
||||
| Equal or unequal overlapping scalar fields in one group | Structured composition conflict with both source locations |
|
||||
| Same destination across deliberate precedence tiers | Existing recursive overlay behavior |
|
||||
| Version-only prompt override | Label changes; omitted prompt body fields survive |
|
||||
| Nested mounted import without a destination directive | Imported fields contribute at the inherited destination |
|
||||
| Local profile inheritance across mounted peer fragments | Parent resolves at the effective sibling scope |
|
||||
| Collection metadata containing `version` mounted below root | Ordinary nested field, independent of root VersionRule |
|
||||
| Mounted/imported contribution actually targeting the protected version path | Rejected under version ownership rules |
|
||||
| Mounted field using source-relative content, including inheritance | Original declaring resource context remains available |
|
||||
| Mounted explicit null and omitted field | Distinct effective presence information |
|
||||
| Mounted validation error or secret field | Effective paths, physical locations, and existing disclosure rules agree |
|
||||
| File, native filesystem, byte, reader, or mapping input with equivalent content | Equivalent application values, subject to existing source contracts |
|
||||
| Unsupported destination or failed load | Normal structured failure, no usable partial result |
|
||||
|
||||
The minimum usable outcome is a consumer that selects collection sources and
|
||||
destinations, performs one ordinary layered load, and receives final typed
|
||||
configuration with reliable diagnostics and inspection. It should need no
|
||||
consumer-side source rewriting, generic merging, or provenance reconstruction.
|
||||
89
docs/roadmap/remediation.md
Normal file
89
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Input destination review findings
|
||||
|
||||
Reviewed `d3d468a..945e111` against the approved
|
||||
[input-destinations roadmap](input-destinations.md),
|
||||
[implementation plan](implementation.md), and
|
||||
[ADR 0011](../adr/0011-input-destinations.md).
|
||||
|
||||
The root-owned destination fields, placement before strict composition,
|
||||
transitive import placement, effective-path version ownership, final lifecycle,
|
||||
source records, resource contexts, retained history, and existing disclosure
|
||||
mechanisms broadly match the selected architecture. The required correction
|
||||
below concerns an early diagnostic path, not loading or composition semantics.
|
||||
No additional unintended strengthening of contracts was identified.
|
||||
|
||||
## 1. Rebase known Mapping-source failure paths to the input destination (P2)
|
||||
|
||||
Status: complete. Known Mapping value-builder paths are now prefixed with the
|
||||
normalized destination at acquisition, preserving semantic key/index segments.
|
||||
Public regressions cover mounted main and underlay failures, escaped keys,
|
||||
sequence indices, cyclic values, unchanged root paths, and source-constructor
|
||||
errors retaining source coordinates. The mounted value cases failed before the
|
||||
fix and pass afterward. Existing directive/syntax checks remain unchanged.
|
||||
Focused destination/source/diagnostic/import tests, `./scripts/check.sh`, and
|
||||
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed on
|
||||
`go1.26.8 linux/amd64`.
|
||||
|
||||
Affected: `assembleBaseInputTracked` in
|
||||
[load_input.go](../../internal/engine/load_input.go), lines 27–32 at the reviewed
|
||||
commit; the Mapping failure originates in `readSource` in
|
||||
[source.go](../../internal/engine/source.go), lines 206–208.
|
||||
|
||||
`Mapping` snapshots its input through the existing value builder. When a nested
|
||||
value is unsupported, cyclic, or exceeds a value-builder limit, the retained
|
||||
issue already identifies a semantic path inside that application mapping.
|
||||
`readSource` turns it into a structured diagnostic. The early return from
|
||||
`assembleBaseInputTracked` annotates the source record with its destination but
|
||||
returns those diagnostic paths unchanged.
|
||||
|
||||
For example, select this input as either main or an underlay:
|
||||
|
||||
```go
|
||||
configkit.Input{
|
||||
Source: configkit.Mapping("bad mapping", map[string]any{
|
||||
"item": map[string]any{"bad": func() {}},
|
||||
}),
|
||||
Destination: configkit.Keys("catalog", "entries"),
|
||||
}
|
||||
```
|
||||
|
||||
The load correctly fails with `source.invalid`, but `Diagnostic.Path` is
|
||||
`/item/bad` rather than `/catalog/entries/item/bad`. The source record correctly
|
||||
reports `/catalog/entries`, so the two inspection records use inconsistent
|
||||
application coordinates. A consumer displaying or associating the failure with
|
||||
the final application shape would point to the wrong configuration field.
|
||||
|
||||
This diverges from implementation decision 10 and Stage 6: existing paths that
|
||||
genuinely identify application content must be translated to effective paths
|
||||
exactly once. A Mapping value-builder issue has that information; it is not a
|
||||
source-read or YAML syntax failure for which a logical child path is unavailable.
|
||||
|
||||
Required remediation:
|
||||
|
||||
- Prefix known application-relative Mapping value-builder diagnostic paths with
|
||||
the normalized input destination at the acquisition boundary. Preserve key
|
||||
escaping and sequence-index intent in the existing semantic Path model.
|
||||
- Keep source IDs, original primary/related locations, codes, value-free
|
||||
messages, source destinations, and nil-result behavior unchanged. Root
|
||||
placement must retain its current paths.
|
||||
- Do not blanket-prefix parse/read failures, source-constructor errors, or
|
||||
structural import-directive diagnostics. Do not invent logical child paths
|
||||
for failures that do not identify application content.
|
||||
- Add bounded regression coverage through the public API for mounted main and
|
||||
underlay Mapping failures, alongside unchanged root placement. Reuse existing
|
||||
syntax/directive tests to protect their different coordinate convention.
|
||||
- Run the focused destination/source/diagnostic checks and the everyday check.
|
||||
Reconcile the completed plan's diagnostic evidence with this correction;
|
||||
do not redefine the agreed effective-path behavior to accept the gap.
|
||||
|
||||
## Review validation
|
||||
|
||||
- `GOTOOLCHAIN=local ./scripts/check.sh` passed at `945e111` on
|
||||
`go1.26.8 linux/amd64` (formatting, whitespace, tests, vet, and build).
|
||||
- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed.
|
||||
- `GOTOOLCHAIN=local GOPROXY=off go test -count=1 ./...` passed.
|
||||
- An additional external-package probe in a disposable checkout outside the
|
||||
repository failed for both main and underlay placement, observing `/item/bad`
|
||||
instead of `/catalog/entries/item/bad` with `source.invalid`.
|
||||
- No production fixes or regression tests were added to this repository during
|
||||
the review. No performance measurements or new guarantees were inferred.
|
||||
159
examples/consumers/destinations.go
Normal file
159
examples/consumers/destinations.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// CollectionBundleConfig is a representative application-owned collection
|
||||
// shape. Collection and prompt version labels remain ordinary domain fields.
|
||||
type CollectionBundleConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Collections map[string]CollectionDefinition `yaml:"collections"`
|
||||
}
|
||||
|
||||
type CollectionDefinition struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Prompts map[string]CollectionPrompt `yaml:"prompts"`
|
||||
Profiles map[string]CollectionProfile `yaml:"profiles"`
|
||||
}
|
||||
|
||||
type CollectionPrompt struct {
|
||||
Version string `yaml:"version"`
|
||||
DefaultProfile string `yaml:"default_profile"`
|
||||
ContentFile string `yaml:"content_file"`
|
||||
ResourceText string `yaml:"resource_text"`
|
||||
Messages []CollectionMessage `yaml:"messages"`
|
||||
}
|
||||
|
||||
type CollectionMessage struct {
|
||||
Role string `yaml:"role"`
|
||||
Content string `yaml:"content"`
|
||||
}
|
||||
|
||||
type CollectionProfile struct {
|
||||
Format string `yaml:"format"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
}
|
||||
|
||||
// LoadCollectionBundle demonstrates one layered collection load. Its main
|
||||
// document owns the application schema version; imported collection metadata,
|
||||
// prompt/profile peers, and an operator prompt-version override use ordinary
|
||||
// destinations and domain fields.
|
||||
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle := configkit.NewFilesystem("collection bundle", files)
|
||||
collectionDestination := configkit.Keys("collections", "weatherreporter")
|
||||
promptDestination := collectionDestination.Key("prompts")
|
||||
profileDestination := collectionDestination.Key("profiles")
|
||||
return configkit.LoadLayered(
|
||||
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
|
||||
configkit.LayeredOptions[CollectionBundleConfig]{
|
||||
Definition: configkit.Definition[CollectionBundleConfig]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
if !version.Present || version.Text != "1" {
|
||||
return errors.New("unsupported application schema version")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
Resolve: resolveCollectionResources,
|
||||
Validate: validateCollectionBundle,
|
||||
},
|
||||
Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{
|
||||
{Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination},
|
||||
{Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination},
|
||||
{Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination},
|
||||
{Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination},
|
||||
{Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination},
|
||||
}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCollectionResources(configuration *CollectionBundleConfig, resolution *configkit.Resolution) error {
|
||||
for collectionID, collection := range configuration.Collections {
|
||||
for promptID, prompt := range collection.Prompts {
|
||||
if prompt.ContentFile == "" {
|
||||
continue
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := resolution.InputInfo(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("prompt resource has no single declaring source")
|
||||
}
|
||||
resourceSource, found := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return fmt.Errorf("prompt resource source is unavailable")
|
||||
}
|
||||
context, found := resolution.SourceContext(resourceSource.ID)
|
||||
if !found {
|
||||
return fmt.Errorf("prompt resource context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read prompt resource: %w", err)
|
||||
}
|
||||
prompt.ResourceText = string(data)
|
||||
collection.Prompts[promptID] = prompt
|
||||
resolution.Record(
|
||||
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
||||
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
|
||||
)
|
||||
}
|
||||
configuration.Collections[collectionID] = collection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCollectionBundle(configuration *CollectionBundleConfig, validation *configkit.Validation) {
|
||||
for collectionID, collection := range configuration.Collections {
|
||||
for promptID, prompt := range collection.Prompts {
|
||||
if prompt.DefaultProfile == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := collection.Profiles[prompt.DefaultProfile]; !found {
|
||||
validation.Error(configkit.Keys("collections", collectionID, "prompts", promptID, "default_profile"), "must name a profile in this collection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadCollectionPromptContent follows the effective content field's physical
|
||||
// source, including when it was inherited from a peer prompt.
|
||||
func ReadCollectionPromptContent(result *configkit.Result[CollectionBundleConfig], collectionID, promptID string) (string, error) {
|
||||
if result == nil {
|
||||
return "", errors.New("collection result is nil")
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := result.Metadata().Lookup(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return "", errors.New("prompt resource has no single declaring source")
|
||||
}
|
||||
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return "", errors.New("prompt resource context is unavailable")
|
||||
}
|
||||
prompt, found := result.Config.Collections[collectionID].Prompts[promptID]
|
||||
if !found {
|
||||
return "", errors.New("prompt is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
116
examples/consumers/destinations_test.go
Normal file
116
examples/consumers/destinations_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_inputDestination() {
|
||||
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Mapping("weather prompt", map[string]any{
|
||||
"summarize.alerts": map[string]any{"version": "1"},
|
||||
}),
|
||||
Destination: configkit.Keys("collections", "weather.reporter", "prompts"),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
root, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Bytes("standalone", []byte("name: standalone\n")),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_, found := mounted.Config["collections"]
|
||||
fmt.Println(found, root.Config["name"])
|
||||
// Output: true standalone
|
||||
}
|
||||
|
||||
func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
||||
result, err := consumers.LoadCollectionBundle()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCollectionBundle: %v", err)
|
||||
}
|
||||
collection := result.Config.Collections["weatherreporter"]
|
||||
if result.Config.Version != "1" || collection.Name != "Weather Reporter" || collection.Version != "2.4.0" {
|
||||
t.Fatalf("main and collection versions = %q, %#v", result.Config.Version, collection)
|
||||
}
|
||||
brief := collection.Prompts["brief"]
|
||||
custom := collection.Prompts["custom"]
|
||||
if brief.Version != "2" || brief.DefaultProfile != "compact" || brief.ContentFile != "welcome.txt" || len(brief.Messages) != 1 {
|
||||
t.Fatalf("version-only override replaced lower prompt fields: %#v", brief)
|
||||
}
|
||||
if custom.Version != "2" || custom.DefaultProfile != "weather" || custom.ContentFile != brief.ContentFile ||
|
||||
len(custom.Messages) != 1 || custom.ResourceText != "Welcome to the weather collection.\n" {
|
||||
t.Fatalf("inherited prompt = %#v", custom)
|
||||
}
|
||||
weatherProfile := collection.Profiles["weather"]
|
||||
if weatherProfile.Format != "text" || weatherProfile.Temperature != 0.4 {
|
||||
t.Fatalf("inherited profile = %#v", weatherProfile)
|
||||
}
|
||||
resource, err := consumers.ReadCollectionPromptContent(result, "weatherreporter", "custom")
|
||||
if err != nil || resource != custom.ResourceText {
|
||||
t.Fatalf("inherited prompt resource = %q, %v", resource, err)
|
||||
}
|
||||
|
||||
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
||||
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
||||
}
|
||||
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
||||
collectionVersion, ok := result.Metadata().Lookup(collectionVersionPath)
|
||||
if !ok || !collectionVersion.Explicit || collectionVersion.Origin.Layer != configkit.LayerUnderlay || len(collectionVersion.Origin.Locations) != 1 {
|
||||
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
||||
}
|
||||
var metadataSource configkit.SourceInfo
|
||||
var parentPromptSource configkit.SourceInfo
|
||||
var promptVersionSource configkit.SourceInfo
|
||||
for _, source := range result.Sources() {
|
||||
switch source.Path {
|
||||
case "collections/weather/metadata.yml":
|
||||
metadataSource = source
|
||||
case "collections/weather/prompts/brief.yml":
|
||||
parentPromptSource = source
|
||||
case "operators/weather-prompt-version.yml":
|
||||
promptVersionSource = source
|
||||
}
|
||||
}
|
||||
collectionVersionLocation := collectionVersion.Origin.Locations[0]
|
||||
if metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID ||
|
||||
metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 {
|
||||
t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation)
|
||||
}
|
||||
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
||||
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
||||
}
|
||||
contentPath := configkit.Keys("collections", "weatherreporter", "prompts", "custom", "content_file")
|
||||
contentInfo, ok := result.Metadata().Lookup(contentPath)
|
||||
if !ok || !contentInfo.Explicit || len(contentInfo.Origin.Inheritance) != 1 || len(contentInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("inherited content field metadata = %#v, %v", contentInfo, ok)
|
||||
}
|
||||
contentLocation := contentInfo.Origin.Locations[0]
|
||||
if parentPromptSource.ID == "" || contentLocation.SourceID != parentPromptSource.ID || contentLocation.Line != 4 ||
|
||||
parentPromptSource.Destination.String() != "/collections/weatherreporter/prompts" {
|
||||
t.Fatalf("inherited resource source coordinates = %#v, source %#v", contentLocation, parentPromptSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPeerPromptConflictsRemainStrict(t *testing.T) {
|
||||
destination := configkit.Keys("collections", "weatherreporter", "prompts")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("peer-a", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||
{Source: configkit.Bytes("peer-b", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||
}}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("duplicate peer prompt error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
//go:embed testdata/native-source testdata/layered-bundle
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle
|
||||
var bundledFiles embed.FS
|
||||
|
||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
name: Weather Reporter
|
||||
2
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
2
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: "2.4.0"
|
||||
description: Imported collection metadata.
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
compact:
|
||||
format: text
|
||||
temperature: 0.2
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
weather:
|
||||
extends: compact
|
||||
temperature: 0.4
|
||||
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
brief:
|
||||
version: "1"
|
||||
default_profile: compact
|
||||
content_file: welcome.txt
|
||||
messages:
|
||||
- role: user
|
||||
content: Write a concise weather summary.
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
custom:
|
||||
extends: brief
|
||||
default_profile: weather
|
||||
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Welcome to the weather collection.
|
||||
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
brief:
|
||||
version: "2"
|
||||
443
facade_destination_test.go
Normal file
443
facade_destination_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
hostPath := filepath.Join(directory, "host.yml")
|
||||
if err := os.WriteFile(hostPath, []byte("value: host\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native := configkit.NewFilesystem("native", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("value: native\n")},
|
||||
})
|
||||
const literalValuePath = "/collections/weather.reporter/profiles/a~1b/~0/0"
|
||||
destination := configkit.Keys("collections", "weather.reporter", "profiles", "a/b", "~", "0")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
want string
|
||||
kind configkit.SourceKind
|
||||
}{
|
||||
{name: "host file", source: configkit.File(hostPath), want: "host", kind: configkit.SourceKindFile},
|
||||
{name: "native filesystem", source: native.File("main.yml"), want: "native", kind: configkit.SourceKindFilesystem},
|
||||
{name: "bytes", source: configkit.Bytes("bytes", []byte("value: bytes\n")), want: "bytes", kind: configkit.SourceKindBytes},
|
||||
{name: "reader", source: configkit.Reader("reader", strings.NewReader("value: reader\n")), want: "reader", kind: configkit.SourceKindReader},
|
||||
{name: "mapping", source: configkit.Mapping("mapping", map[string]any{"value": "mapping"}), want: "mapping", kind: configkit.SourceKindMapping},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: destination})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if got := facadeDestinationValue(t, result.Config, "collections", "weather.reporter", "profiles", "a/b", "~", "0", "value"); got != test.want {
|
||||
t.Fatalf("placed value = %#v, want %q", got, test.want)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].Destination.String() != destination.String() || destination.String() != literalValuePath {
|
||||
t.Fatalf("source placement = %#v, destination = %q", sources, destination.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
destination configkit.Path
|
||||
}{
|
||||
{name: "omitted destination"},
|
||||
{name: "explicit empty destination", destination: configkit.Path{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("root", []byte("value: root\n")), Destination: test.destination})
|
||||
if err != nil {
|
||||
t.Fatalf("root load: %v", err)
|
||||
}
|
||||
if result.Config["value"] != "root" || len(result.Sources()) != 1 || result.Sources()[0].Destination.String() != "" {
|
||||
t.Fatalf("root placement = %#v, sources %#v", result.Config, result.Sources())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
emptyPath := configkit.Keys("collections", "empty")
|
||||
empty, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Mapping("empty", map[string]any{}), Destination: emptyPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty mapping load: %v", err)
|
||||
}
|
||||
if value := facadeDestinationMap(t, empty.Config, "collections")["empty"]; value == nil {
|
||||
t.Fatal("empty mapping did not materialize its destination")
|
||||
}
|
||||
if info, ok := empty.Metadata().Lookup(emptyPath); !ok || !info.Explicit {
|
||||
t.Fatalf("empty destination metadata = %#v, %v", info, ok)
|
||||
}
|
||||
|
||||
absentPath := configkit.Keys("collections", "absent")
|
||||
absent, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource(), Destination: absentPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource with a destination: %v", err)
|
||||
}
|
||||
if len(absent.Sources()) != 0 {
|
||||
t.Fatalf("NoSource created source records: %#v", absent.Sources())
|
||||
}
|
||||
if _, ok := absent.Metadata().Lookup(absentPath); ok {
|
||||
t.Fatal("NoSource materialized its destination")
|
||||
}
|
||||
|
||||
reader := &destinationCountingReader{}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
destination configkit.Path
|
||||
wantReads int
|
||||
}{
|
||||
{name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)},
|
||||
{name: "reserved configkit key", source: configkit.NoSource(), destination: configkit.Keys("configkit")},
|
||||
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: test.destination})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("invalid destination error = %#v", err)
|
||||
}
|
||||
if reader.reads != test.wantReads {
|
||||
t.Fatalf("reader calls = %d, want %d", reader.reads, test.wantReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
fragmentPath := filepath.Join(directory, "fragment.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destination := configkit.Keys("collections", "weatherreporter")
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: destination,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted import load: %v", err)
|
||||
}
|
||||
collection := facadeDestinationMap(t, result.Config, "collections", "weatherreporter")
|
||||
if collection["main"] != "present" || collection["imported"] != "present" {
|
||||
t.Fatalf("mounted imported values = %#v", collection)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||
t.Fatalf("mounted import source destinations = %#v", sources)
|
||||
}
|
||||
if sources[1].ImportedFrom == nil {
|
||||
t.Fatalf("imported source lacks its authored directive: %#v", sources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
||||
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
||||
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
||||
result, err := configkit.LoadLayered(
|
||||
configkit.Input{Source: configkit.NoSource()},
|
||||
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("weather", []byte("summarize.alerts: {title: weather}\n")), Destination: weatherDestination},
|
||||
{Source: configkit.Bytes("homewatch", []byte("summarize.alerts: {title: home}\n")), Destination: homeDestination},
|
||||
}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{path: []string{"collections", "weather", "prompts", "summarize.alerts", "title"}, want: "weather"},
|
||||
{path: []string{"collections", "homewatch", "prompts", "summarize.alerts", "title"}, want: "home"},
|
||||
} {
|
||||
if got := facadeDestinationValue(t, result.Config, test.path...); got != test.want {
|
||||
t.Errorf("value at %v = %#v, want %q", test.path, got, test.want)
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != weatherDestination.String() || sources[1].Destination.String() != homeDestination.String() {
|
||||
t.Fatalf("independent collection source placements = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
type destinationCountingReader struct{ reads int }
|
||||
|
||||
func (reader *destinationCountingReader) Read([]byte) (int, error) {
|
||||
reader.reads++
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := configkit.NewFilesystem("resources", fstest.MapFS{
|
||||
"assets/summary.md": &fstest.MapFile{Data: []byte("source-relative summary")},
|
||||
})
|
||||
context := &configkit.FilesystemContext{Root: ".", BaseDir: "assets", FS: &filesystem}
|
||||
destination := configkit.Keys("catalog", "entries")
|
||||
tokenPath := configkit.Keys("catalog", "entries", "item", "token")
|
||||
titlePath := configkit.Keys("catalog", "entries", "item", "title")
|
||||
var callbacks struct{ resolve, validate int }
|
||||
load := func(token string) *configkit.Result[map[string]any] {
|
||||
t.Helper()
|
||||
main := configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("item:\n title: main\n resource: summary.md\n token: "+token+"\n")),
|
||||
Destination: destination, Filesystem: context,
|
||||
}
|
||||
underlay := configkit.Input{
|
||||
Source: configkit.Bytes("underlay", []byte("item:\n title: lower\n retained: lower-value\n token: LOWER-TOKEN-CANARY\n")),
|
||||
Destination: destination,
|
||||
}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||
callbacks.resolve++
|
||||
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
||||
input, ok := resolution.InputInfo(resourcePath)
|
||||
if !ok || len(input.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("mounted resource input has no single source origin")
|
||||
}
|
||||
source, ok := resolution.Source(input.Origin.Locations[0].SourceID)
|
||||
if !ok || source.Destination.String() != destination.String() {
|
||||
return fmt.Errorf("resolution source did not retain its destination")
|
||||
}
|
||||
resourceContext, ok := resolution.SourceContext(source.ID)
|
||||
if !ok {
|
||||
return fmt.Errorf("resolution source context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(resourceContext.FS, path.Join(resourceContext.BaseDir, "summary.md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source-relative resource: %w", err)
|
||||
}
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
||||
entry["resource_text"] = string(data)
|
||||
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
|
||||
return nil
|
||||
},
|
||||
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted load: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
first := load("FIRST-TOKEN-CANARY")
|
||||
second := load("SECOND-TOKEN-CANARY")
|
||||
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||
t.Fatalf("lifecycle callbacks = %#v", callbacks)
|
||||
}
|
||||
for _, result := range []*configkit.Result[map[string]any]{first, second} {
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, result.Config, "catalog"), "entries"), "item")
|
||||
if entry["title"] != "main" || entry["retained"] != "lower-value" || entry["resource_text"] != "source-relative summary" {
|
||||
t.Fatalf("mounted main/underlay configuration = %#v", entry)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||
t.Fatalf("public source destinations = %#v", sources)
|
||||
}
|
||||
resource, ok := result.SourceContext(sources[0].ID)
|
||||
if !ok || resource.BaseDir != "assets" {
|
||||
t.Fatalf("completed source context = %#v, %v", resource, ok)
|
||||
}
|
||||
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, "summary.md"))
|
||||
if err != nil || string(data) != "source-relative summary" {
|
||||
t.Fatalf("completed relative resource = %q, %v", data, err)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
|
||||
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
||||
}
|
||||
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
||||
t.Fatalf("mounted title history = %#v", history)
|
||||
}
|
||||
if explanation, ok := result.Explain(tokenPath); !ok || !explanation.Effective.Redacted {
|
||||
t.Fatalf("mounted secret explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
var effective, explanation bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := result.WriteExplanation(&explanation, tokenPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := effective.String() + explanation.String()
|
||||
for _, canary := range []string{"FIRST-TOKEN-CANARY", "SECOND-TOKEN-CANARY", "LOWER-TOKEN-CANARY"} {
|
||||
if strings.Contains(output, canary) {
|
||||
t.Fatalf("public inspection leaked %q: %s", canary, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mutating published records and query copies must not alter the retained result.
|
||||
returnedSources := first.Sources()
|
||||
returnedSources[0].Destination = configkit.Keys("mutated")
|
||||
if got := first.Sources()[0].Destination.String(); got != destination.String() {
|
||||
t.Fatalf("source destination changed after caller mutation: %q", got)
|
||||
}
|
||||
info, ok := first.Metadata().Lookup(titlePath)
|
||||
if !ok {
|
||||
t.Fatal("mounted metadata lookup failed")
|
||||
}
|
||||
info.Origin.Locations[0].SourceID = "mutated"
|
||||
if fresh, ok := first.Metadata().Lookup(titlePath); !ok || fresh.Origin.Locations[0].SourceID == "mutated" {
|
||||
t.Fatalf("metadata copy mutation escaped: %#v, %v", fresh, ok)
|
||||
}
|
||||
history := first.Metadata().History(titlePath)
|
||||
if len(history) == 0 {
|
||||
t.Fatal("mounted history is empty")
|
||||
}
|
||||
history[0].Path = configkit.Keys("mutated")
|
||||
if fresh := first.Metadata().History(titlePath); len(fresh) == 0 || fresh[0].Path.String() != titlePath.String() {
|
||||
t.Fatalf("history copy mutation escaped: %#v", fresh)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(first, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 1 || differences[0].Path.String() != tokenPath.String() ||
|
||||
!differences[0].Left.Redacted || !differences[0].Right.Redacted ||
|
||||
strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "TOKEN-CANARY") {
|
||||
t.Fatalf("public mounted secret diff = %#v", differences)
|
||||
}
|
||||
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||
t.Fatalf("inspection reran lifecycle callbacks: %#v", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
|
||||
type configuration struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
destination := configkit.Keys("tenant")
|
||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||
Source: configkit.Bytes("invalid", []byte("count: not-an-integer\n")), Destination: destination,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("failed source destinations = %#v", sources)
|
||||
}
|
||||
sources[0].Destination = configkit.Keys("caller-change")
|
||||
if fresh := loadError.Sources(); len(fresh) != 1 || fresh[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("failed source destination was not detached: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
path func(configkit.Path) configkit.Path
|
||||
}{
|
||||
{
|
||||
name: "unsupported nested sequence value",
|
||||
values: map[string]any{"a/b": []any{map[string]any{"~": func() {}}}},
|
||||
path: func(base configkit.Path) configkit.Path { return base.Key("a/b").Index(0).Key("~") },
|
||||
},
|
||||
{
|
||||
name: "cyclic value",
|
||||
values: map[string]any{"item": cycle},
|
||||
path: func(base configkit.Path) configkit.Path { return base.Key("item").Key("self") },
|
||||
},
|
||||
{
|
||||
name: "constructor failure keeps source coordinates",
|
||||
path: func(configkit.Path) configkit.Path { return configkit.Path{} },
|
||||
},
|
||||
} {
|
||||
t.Run(fixture.name, func(t *testing.T) {
|
||||
for _, role := range []string{"root", "mounted main", "mounted underlay"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
var destination configkit.Path
|
||||
if role != "root" {
|
||||
destination = configkit.Keys("catalog", "entries")
|
||||
}
|
||||
wantPath := fixture.path(destination)
|
||||
input := configkit.Input{Source: configkit.Mapping("invalid mapping", fixture.values), Destination: destination}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if role == "mounted underlay" {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||
})
|
||||
} else {
|
||||
result, err = configkit.LoadLayered[map[string]any](input)
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if result != nil || !errors.As(err, &loadError) {
|
||||
t.Fatalf("failed load = %#v, %v", result, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
diagnostic := diagnostics[0]
|
||||
if diagnostic.Code != configkit.CodeSourceInvalid || !reflect.DeepEqual(diagnostic.Path, wantPath) {
|
||||
t.Fatalf("diagnostic = %#v; want source.invalid at %q", diagnostic, wantPath.String())
|
||||
}
|
||||
if diagnostic.Primary == nil || *diagnostic.Primary != (configkit.Location{SourceID: "source-1"}) || len(diagnostic.Related) != 0 {
|
||||
t.Fatalf("mapping source locations = %#v, %#v", diagnostic.Primary, diagnostic.Related)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("error sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func facadeDestinationMap(t *testing.T, value map[string]any, keys ...string) map[string]any {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
child, ok := value[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||
}
|
||||
value = child
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func facadeDestinationValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||
t.Helper()
|
||||
return facadeDestinationMap(t, value, keys[:len(keys)-1]...)[keys[len(keys)-1]]
|
||||
}
|
||||
127
internal/engine/destination.go
Normal file
127
internal/engine/destination.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidDestination = errors.New("configkit: destination must contain only non-reserved mapping keys")
|
||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||
)
|
||||
|
||||
func normalizeDestination(path Path) (Path, error) {
|
||||
if !path.valid() {
|
||||
return Path{}, errInvalidDestination
|
||||
}
|
||||
|
||||
segments := make([]pathSegment, 0, len(path.segments))
|
||||
for _, segment := range path.segments {
|
||||
if segment.kind != pathSegmentKey && segment.kind != pathSegmentParsed {
|
||||
return Path{}, errInvalidDestination
|
||||
}
|
||||
if segment.key == "configkit" || segment.key == "extends" {
|
||||
return Path{}, errInvalidDestination
|
||||
}
|
||||
segments = append(segments, pathSegment{kind: pathSegmentKey, key: segment.key})
|
||||
}
|
||||
return Path{segments: segments}, nil
|
||||
}
|
||||
|
||||
func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: CodeOptionsInvalid, Severity: SeverityError,
|
||||
Path: destination.clone(),
|
||||
Message: "input destination must contain only non-reserved mapping keys",
|
||||
}
|
||||
}
|
||||
|
||||
func setSourceDestinations(sources []SourceInfo, destination Path) {
|
||||
for index := range sources {
|
||||
sources[index].Destination = destination.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// placeDocument checks all per-document bounds before allocating destination
|
||||
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
normalized, err := normalizeDestination(destination)
|
||||
if err != nil {
|
||||
return Path{}, nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return normalized, nil, nil
|
||||
}
|
||||
if !destinationWithinLimits(body, normalized, maxDepth, maxNodes) {
|
||||
return normalized, nil, errDestinationLimit
|
||||
}
|
||||
|
||||
placed := body
|
||||
for index := len(normalized.segments) - 1; index >= 0; index-- {
|
||||
key := normalized.segments[index].key
|
||||
placed = &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Location: sourceRoot,
|
||||
Mapping: []document.Entry{{
|
||||
Key: key,
|
||||
KeyLocation: sourceRoot,
|
||||
ValueLocation: sourceRoot,
|
||||
Value: placed,
|
||||
}},
|
||||
}
|
||||
}
|
||||
return normalized, placed, nil
|
||||
}
|
||||
|
||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||
if body == nil {
|
||||
return true
|
||||
}
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() {
|
||||
return false
|
||||
}
|
||||
segments := len(destination.segments)
|
||||
if segments > maxDepth || segments > maxNodes/2 {
|
||||
return false
|
||||
}
|
||||
wrapperNodes := destinationWrapperNodeCount(destination)
|
||||
for _, segment := range destination.segments {
|
||||
if segment.kind != pathSegmentKey {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if documentDepth(body) > maxDepth-segments {
|
||||
return false
|
||||
}
|
||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCount(destination Path) int {
|
||||
return 2 * len(destination.segments)
|
||||
}
|
||||
|
||||
func documentDepth(node *document.Node) int {
|
||||
if node == nil {
|
||||
return -1
|
||||
}
|
||||
depth := 0
|
||||
var visit func(*document.Node, int)
|
||||
visit = func(current *document.Node, currentDepth int) {
|
||||
if current == nil {
|
||||
return
|
||||
}
|
||||
if currentDepth > depth {
|
||||
depth = currentDepth
|
||||
}
|
||||
for _, entry := range current.Mapping {
|
||||
visit(entry.Value, currentDepth+1)
|
||||
}
|
||||
for _, child := range current.Sequence {
|
||||
visit(child, currentDepth+1)
|
||||
}
|
||||
}
|
||||
visit(node, 0)
|
||||
return depth
|
||||
}
|
||||
176
internal/engine/destination_accounting_test.go
Normal file
176
internal/engine/destination_accounting_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := defaultImportLimits()
|
||||
destination := Keys("collections", "entry")
|
||||
empty := Mapping("empty", map[string]any{})
|
||||
parsed, diagnostics := parseSource(empty, "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
perInput := countInputNodes(parsed.document) + destinationWrapperNodeCount(destination)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
wantCharge int
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1, wantCharge: perInput},
|
||||
{name: "at aggregate bound", delta: 0, wantCharge: perInput},
|
||||
{name: "above aggregate bound", delta: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: empty, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
|
||||
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := defaultImportLimits()
|
||||
inputs := []loadInput{
|
||||
{source: Mapping("one", map[string]any{}), destination: Keys("collection", "one")},
|
||||
{source: Mapping("two", map[string]any{}), destination: Keys("collection", "two")},
|
||||
{source: Mapping("three", map[string]any{}), destination: Keys("collection", "three")},
|
||||
}
|
||||
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||
charge := len(inputs) * perInput
|
||||
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||
"fragment.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||
})
|
||||
mainSource := filesystem.File("main.yml")
|
||||
mainParsed, diagnostics := parseSource(mainSource, "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
fragmentParsed, diagnostics := parseSource(Bytes("fragment", []byte("{}\n")), "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
rawNodes := countInputNodes(mainParsed.document) + countInputNodes(fragmentParsed.document)
|
||||
destination := Keys("collection", "empty")
|
||||
placementNodes := 2 * destinationWrapperNodeCount(destination)
|
||||
limits := defaultImportLimits()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1},
|
||||
{name: "at aggregate bound", delta: 0},
|
||||
{name: "above aggregate bound", delta: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: mainSource, imports: true, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("over-budget imports = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded imports = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||
}
|
||||
if composition.inputNodes != rawNodes+placementNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("import node charge = %d, accounting %d; want %d and %d", composition.inputNodes, accounting.nodes, rawNodes+placementNodes, limits.nodes+test.delta)
|
||||
}
|
||||
if node, present := documentAtPath(composition.root, Keys("collection", "empty")); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("placed empty import body = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounting := &importAccounting{}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
{source: NoSource(), destination: Keys("absent")},
|
||||
{source: Mapping("root", map[string]any{})},
|
||||
}}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
|
||||
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := defaultImportLimits()
|
||||
baseNodes := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("mounted"))
|
||||
|
||||
t.Run("selected overlay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"overlays/test.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||
})
|
||||
request := loadRequest[map[string]any]{
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
|
||||
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
|
||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||
t.Fatalf("overlay diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CLI override", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
override := Override{Path: Keys("mounted", "value"), Value: "x", Flag: "--value"}
|
||||
builder := valueBuilder{sourceID: "fixture", active: make(map[valueVisit]bool)}
|
||||
if _, issue := builder.build(override.Value, override.Path, len(override.Path.segments)); issue != nil {
|
||||
t.Fatal(issue)
|
||||
}
|
||||
overrideNodes := builder.nodes + destinationWrapperNodeCount(override.Path)
|
||||
request := loadRequest[map[string]any]{
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
|
||||
overrides: []Override{override},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
|
||||
t.Fatalf("override diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
142
internal/engine/destination_diagnostic_test.go
Normal file
142
internal/engine/destination_diagnostic_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("strict conflict retains both source locations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: Bytes("left", []byte("section:\n value: left\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte("section:\n value: right\n")), Destination: Keys("application")},
|
||||
}}}},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("conflicting load returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/application/section/value" {
|
||||
t.Fatalf("conflict diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
|
||||
t.Fatalf("primary location = %#v", diagnostic.Primary)
|
||||
}
|
||||
if len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "source-2" || diagnostic.Related[0].Line != 2 {
|
||||
t.Fatalf("related locations = %#v", diagnostic.Related)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode failure keeps effective path and content line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
type section struct {
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
type config struct {
|
||||
Application struct {
|
||||
Section section `yaml:"section"`
|
||||
} `yaml:"application"`
|
||||
}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("section:\n count: invalid\n")), Destination: Keys("application")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("invalid load returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeDecodeType || diagnostic.Path.String() != "/application/section/count" {
|
||||
t.Fatalf("decode diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
|
||||
t.Fatalf("decode location = %#v", diagnostic.Primary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("imported version failure uses effective path and import source", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "version: forbidden\n")
|
||||
rule := &VersionRule{Path: Keys("application", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("application"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("invalid import returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeImportVersion || diagnostic.Path.String() != "/application/version" {
|
||||
t.Fatalf("import version diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 {
|
||||
t.Fatalf("import version location = %#v", diagnostic.Primary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid destination has no fictional source location", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("value: present\n")), Destination: Keys("configkit")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("invalid destination returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
|
||||
t.Fatalf("invalid destination diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed source directive keeps its local path and location", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [true]}\nvalue: present\n")},
|
||||
})
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("application")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("malformed directive returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("malformed directive diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 1 {
|
||||
t.Fatalf("malformed directive location = %#v", diagnostic.Primary)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func firstDestinationDiagnostic(t *testing.T, err error) Diagnostic {
|
||||
t.Helper()
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) == 0 {
|
||||
t.Fatal("load error has no diagnostics")
|
||||
}
|
||||
return diagnostics[0]
|
||||
}
|
||||
208
internal/engine/destination_import_test.go
Normal file
208
internal/engine/destination_import_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
type importDestinationConfig struct {
|
||||
Mounted map[string]string `yaml:"mounted"`
|
||||
Collections map[string]map[string]string `yaml:"collections"`
|
||||
}
|
||||
|
||||
func TestLayeredHostImportsInheritInputDestinationTransitively(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: first.yml
|
||||
- path: second.yml
|
||||
root: main
|
||||
`)
|
||||
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/third.yml
|
||||
first: one
|
||||
`)
|
||||
thirdPath := writeImportFile(t, directory, "nested/third.yml", "nested: three\n")
|
||||
secondPath := writeImportFile(t, directory, "second.yml", "second: two\n")
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("mounted")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
want := map[string]string{"root": "main", "first": "one", "nested": "three", "second": "two"}
|
||||
if len(result.Config.Mounted) != len(want) {
|
||||
t.Fatalf("mounted values = %#v", result.Config.Mounted)
|
||||
}
|
||||
for key, value := range want {
|
||||
if result.Config.Mounted[key] != value {
|
||||
t.Errorf("mounted[%q] = %q, want %q", key, result.Config.Mounted[key], value)
|
||||
}
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("mounted", "configkit")); ok {
|
||||
t.Fatal("source-root import directive leaked into application content")
|
||||
}
|
||||
sources := result.Sources()
|
||||
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.Destination.String() != "/mounted" {
|
||||
t.Errorf("source %d destination = %q", index, source.Destination.String())
|
||||
}
|
||||
if source.Path != wantPaths[index] {
|
||||
t.Errorf("source %d path = %q, want %q", index, source.Path, wantPaths[index])
|
||||
}
|
||||
if index > 0 && source.ImportedFrom == nil {
|
||||
t.Errorf("imported source %d has no physical import location", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
native := fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: first.yml\nroot: main\n")},
|
||||
"first.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: nested.yml}]}\nfirst: imported\n")},
|
||||
"nested.yml": &fstest.MapFile{Data: []byte("nested: imported\n")},
|
||||
}
|
||||
namespace := newNativeNamespace("bundle", native)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: filesystemFile(namespace, "main.yml"), Imports: true, Destination: Keys("mounted")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if result.Config.Mounted["root"] != "main" || result.Config.Mounted["first"] != "imported" || result.Config.Mounted["nested"] != "imported" {
|
||||
t.Fatalf("mounted native filesystem values = %#v", result.Config.Mounted)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" || sources[2].Destination.String() != "/mounted" {
|
||||
t.Fatalf("native source destinations = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
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")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
info, ok := result.Metadata().Lookup(Keys("collections", "empty"))
|
||||
if !ok || !info.Explicit {
|
||||
t.Fatalf("import-only body destination = %#v, %v", info, ok)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != "/collections/empty" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredImportedVersionClaimsUseEffectiveDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rulePath Path
|
||||
destination Path
|
||||
wantCode Code
|
||||
}{
|
||||
{name: "protected effective path", rulePath: Keys("mounted", "version"), destination: Keys("mounted"), wantCode: CodeImportVersion},
|
||||
{name: "ordinary nested domain version", rulePath: Keys("version"), destination: Keys("collections", "entry")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "version: v2\n")
|
||||
rule := &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{
|
||||
Definition: Definition[importDestinationConfig]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: test.destination,
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if test.wantCode != "" {
|
||||
if err == nil {
|
||||
t.Fatal("protected imported version claim was accepted")
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.wantCode {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
if test.name == "protected effective path" {
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" {
|
||||
t.Fatalf("error source destinations = %#v", sources)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary nested version was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := result.Config.Collections["entry"]["version"]; got != "v2" {
|
||||
t.Fatalf("nested version = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "shared.yml", "value: shared\n")
|
||||
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml}]}\n")
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: File(importerPath), Imports: true, Destination: Keys("imported")},
|
||||
{Source: File(filepath.Join(directory, "shared.yml")), Destination: Keys("direct")},
|
||||
}}}},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("the same physical source was accepted at two destinations in one group")
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
}
|
||||
463
internal/engine/destination_integration_test.go
Normal file
463
internal/engine/destination_integration_test.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationInheritanceUsesFinalSiblingScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("parent:\n label: final\n settings:\n primary: {height: 4}\ncancelled:\n extends: null\n own: retained\n")), Destination: Keys("catalog", "entries")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: Bytes("parent", []byte("parent:\n label: lower\n settings:\n primary: {width: '10'}\n secondary: {extends: primary}\ncancelled:\n extends: parent\n own: retained\n")), Destination: Keys("catalog", "entries")},
|
||||
{Source: Bytes("child", []byte("child:\n extends: parent\n settings:\n primary: {width: '20'}\n secondary: {extends: primary}\n")), Destination: Keys("catalog", "entries")},
|
||||
}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
entries := layeredMap(t, layeredMap(t, result.Config, "catalog"), "entries")
|
||||
parent := layeredMap(t, entries, "parent")
|
||||
child := layeredMap(t, entries, "child")
|
||||
parentSettings := layeredMap(t, parent, "settings")
|
||||
childSettings := layeredMap(t, child, "settings")
|
||||
if parent["label"] != "final" || child["label"] != "final" {
|
||||
t.Fatalf("higher-tier parent was not inherited after precedence: parent=%#v child=%#v", parent, child)
|
||||
}
|
||||
if layeredMapValue(t, parentSettings, "secondary", "width") != "10" ||
|
||||
fmt.Sprint(layeredMapValue(t, parentSettings, "secondary", "height")) != "4" ||
|
||||
layeredMapValue(t, childSettings, "secondary", "width") != "20" ||
|
||||
fmt.Sprint(layeredMapValue(t, childSettings, "secondary", "height")) != "4" {
|
||||
t.Fatalf("nested sibling scopes were not resolved at their destinations: parent=%#v child=%#v", parentSettings, childSettings)
|
||||
}
|
||||
cancelled := layeredMap(t, entries, "cancelled")
|
||||
if len(cancelled) != 1 || cancelled["own"] != "retained" {
|
||||
t.Fatalf("higher-tier cancellation did not suppress inheritance: %#v", cancelled)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", inheritanceKey)); ok {
|
||||
t.Fatal("effective child retained its extends directive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationInheritanceDoesNotSearchOtherCollections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("parent: {value: foreign}\n")), Destination: Keys("catalog", "other")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("child", []byte("child: {extends: parent}\n")),
|
||||
Destination: Keys("catalog", "entries"),
|
||||
}}}}},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeInheritanceMissing {
|
||||
t.Fatalf("cross-collection parent lookup error = %s, want %s", got, CodeInheritanceMissing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
rulePath Path
|
||||
}{
|
||||
{name: "inherited protected value", body: "parent: {version: v2}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "version")},
|
||||
{name: "inherited protected ancestor", body: "parent: {schema: null}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "schema", "version")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: Bytes("underlay", []byte(test.body)), Destination: Keys("catalog", "entries")}}}},
|
||||
},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
|
||||
t.Fatalf("inheritance version protection code = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("inheritance consumes a protected directive", func(t *testing.T) {
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("parent: {value: inherited}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
|
||||
Path: Keys("catalog", "entries", "child", "extends"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
if !version.Present || version.Text != "parent" {
|
||||
t.Errorf("mounted protected directive value = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
|
||||
t.Fatalf("consumed directive protection code = %s", got)
|
||||
}
|
||||
})
|
||||
t.Run("inherited mapping ancestor without a version claim", func(t *testing.T) {
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "entries", "child", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("underlay", []byte("parent: {schema: {name: ordinary}}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("nonclaiming inherited mapping ancestor was rejected: %v", err)
|
||||
}
|
||||
if got := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "schema", "name"); got != "ordinary" {
|
||||
t.Fatalf("inherited non-version value = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "overlay protected value", body: "catalog: {schema: {version: v2}}\n"},
|
||||
{name: "overlay protected ancestor", body: "catalog: {schema: null}\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := NewFilesystem("overlay", fstest.MapFS{"overlays/test.yml": &fstest.MapFile{Data: []byte(test.body)}})
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||
if !version.Present || version.Text != "v1" {
|
||||
t.Errorf("mounted main version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}}},
|
||||
Overlay: "test", OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeOverlayVersion {
|
||||
t.Fatalf("overlay version protection code = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path Path
|
||||
value any
|
||||
}{
|
||||
{name: "override protected value", path: Keys("catalog", "schema", "version"), value: "v2"},
|
||||
{name: "override protected ancestor", path: Keys("catalog", "schema"), value: map[string]any{"version": "v2"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||
Overrides: []Override{{Path: test.path, Value: test.value, Flag: "--config"}},
|
||||
},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeOverrideVersion {
|
||||
t.Fatalf("override version protection code = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("unrelated inheritance under the protected mapping ancestor", func(t *testing.T) {
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("schema: {version: v1}\nentries:\n parent: {value: inherited}\n child: {extends: parent}\n")), Destination: Keys("catalog")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated mounted inheritance was rejected: %v", err)
|
||||
}
|
||||
child := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "value")
|
||||
if child != "inherited" {
|
||||
t.Fatalf("unrelated inheritance value = %#v", child)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type destinationDecodeProbe string
|
||||
|
||||
var destinationDecodeProbeValues []string
|
||||
|
||||
func (value *destinationDecodeProbe) UnmarshalText(text []byte) error {
|
||||
destinationDecodeProbeValues = append(destinationDecodeProbeValues, string(text))
|
||||
*value = destinationDecodeProbe(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (value destinationDecodeProbe) MarshalText() ([]byte, error) {
|
||||
return []byte(value), nil
|
||||
}
|
||||
|
||||
type destinationLifecycleConfig struct {
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
Tenant struct {
|
||||
Tagged string `yaml:"tagged" configkit:"secret"`
|
||||
Dynamic string `yaml:"dynamic"`
|
||||
Credential Secret `yaml:"credential"`
|
||||
Derived string `yaml:"derived"`
|
||||
Decoded destinationDecodeProbe `yaml:"decoded"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
|
||||
func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testing.T) {
|
||||
const (
|
||||
oldTagged = "SUPERSEDED-TAGGED-CANARY"
|
||||
newTagged = "CURRENT-TAGGED-CANARY"
|
||||
oldDynamic = "SUPERSEDED-DYNAMIC-CANARY"
|
||||
newDynamic = "CURRENT-DYNAMIC-CANARY"
|
||||
credential = "RESOLVED-CREDENTIAL-CANARY"
|
||||
secretName = "DESTINATION_SECRET_NAME"
|
||||
decodeValue = "decoded-once"
|
||||
)
|
||||
type calls struct{ defaults, resolve, lookup, validate int }
|
||||
newLoad := func(mounted bool, observed *calls) (*testResult[destinationLifecycleConfig], error) {
|
||||
mainBody := "tagged: " + newTagged + "\ndynamic: " + newDynamic + "\ncredential: " + secretName + "\ndecoded: " + decodeValue + "\n"
|
||||
underlayBody := "tagged: " + oldTagged + "\ndynamic: " + oldDynamic + "\ncredential: OLD_DESTINATION_SECRET_NAME\ndecoded: old\n"
|
||||
if !mounted {
|
||||
mainBody = "tenant:\n" + indentDestinationBody(mainBody)
|
||||
underlayBody = "tenant:\n" + indentDestinationBody(underlayBody)
|
||||
}
|
||||
main := loadInput{source: Bytes("main", []byte(mainBody))}
|
||||
underlay := loadInput{source: Bytes("underlay", []byte(underlayBody))}
|
||||
if mounted {
|
||||
main.destination = Keys("tenant")
|
||||
underlay.destination = Keys("tenant")
|
||||
}
|
||||
definition := Definition[destinationLifecycleConfig]{
|
||||
Defaults: func() destinationLifecycleConfig {
|
||||
observed.defaults++
|
||||
return destinationLifecycleConfig{Defaulted: "from-default"}
|
||||
},
|
||||
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
|
||||
observed.resolve++
|
||||
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
|
||||
observed.validate++
|
||||
if configuration.Tenant.Decoded != destinationDecodeProbe(decodeValue) || configuration.Tenant.Dynamic != newDynamic {
|
||||
t.Errorf("validation saw a non-final mounted configuration: %#v", configuration.Tenant)
|
||||
}
|
||||
},
|
||||
Secret: func(path Path) bool { return path.String() == "/tenant/dynamic" },
|
||||
}
|
||||
loaded, err := loadGroupedWithSecretLookup(loadRequest[destinationLifecycleConfig]{
|
||||
main: main, underlays: []loadGroup{{inputs: []loadInput{underlay}}}, definition: definition,
|
||||
}, testSecretBinding(), func(name string) (string, bool) {
|
||||
observed.lookup++
|
||||
if name != secretName {
|
||||
return "", false
|
||||
}
|
||||
return credential, true
|
||||
})
|
||||
return testLoaded(loaded), err
|
||||
}
|
||||
|
||||
var observed calls
|
||||
destinationDecodeProbeValues = nil
|
||||
mounted, err := newLoad(true, &observed)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("mounted load: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("mounted load: %v", err)
|
||||
}
|
||||
if observed != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
|
||||
t.Fatalf("mounted lifecycle counts = %#v", observed)
|
||||
}
|
||||
sawFinalDecode := false
|
||||
for _, value := range destinationDecodeProbeValues {
|
||||
if value == decodeValue {
|
||||
sawFinalDecode = true
|
||||
} else if value != "" {
|
||||
t.Fatalf("decoder observed a non-final mounted value %q: %#v", value, destinationDecodeProbeValues)
|
||||
}
|
||||
}
|
||||
if !sawFinalDecode {
|
||||
t.Fatal("final mounted value was not decoded")
|
||||
}
|
||||
if mounted.Config.Defaulted != "from-default" || mounted.Config.Tenant.Tagged != newTagged || mounted.Config.Tenant.Dynamic != newDynamic ||
|
||||
mounted.Config.Tenant.Derived != newDynamic || mounted.Config.Tenant.Decoded != destinationDecodeProbe(decodeValue) ||
|
||||
mounted.Config.Tenant.Credential.Value() != credential {
|
||||
t.Fatalf("mounted final configuration = %#v", mounted.Config)
|
||||
}
|
||||
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
|
||||
if !mounted.Inspection.secrets[path.String()] {
|
||||
t.Errorf("effective sensitivity missing at %s: %#v", path, mounted.Inspection.secrets)
|
||||
}
|
||||
}
|
||||
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
|
||||
explanation, ok := mounted.Explain(path)
|
||||
if !ok || !explanation.Effective.Redacted {
|
||||
t.Errorf("effective explanation at %s = %#v, %v", path, explanation, ok)
|
||||
continue
|
||||
}
|
||||
for _, change := range explanation.History {
|
||||
if strings.Contains(change.Before.YAML+change.After.YAML, oldTagged) ||
|
||||
strings.Contains(change.Before.YAML+change.After.YAML, newTagged) ||
|
||||
strings.Contains(change.Before.YAML+change.After.YAML, oldDynamic) ||
|
||||
strings.Contains(change.Before.YAML+change.After.YAML, newDynamic) {
|
||||
t.Errorf("classified history leaked a canary at %s: %#v", path, change)
|
||||
}
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := mounted.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), "CANARY") {
|
||||
t.Errorf("safe explanation at %s = %q, %v", path, rendered.String(), err)
|
||||
}
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := mounted.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, canary := range []string{oldTagged, newTagged, oldDynamic, newDynamic, credential} {
|
||||
if strings.Contains(effective.String(), canary) {
|
||||
t.Fatalf("effective rendering exposed %q: %s", canary, effective.String())
|
||||
}
|
||||
}
|
||||
if serializedConfig := fmt.Sprintf("%#v", mounted.Config); strings.Contains(serializedConfig, credential) {
|
||||
t.Fatalf("configuration formatting exposed the resolved credential: %s", serializedConfig)
|
||||
}
|
||||
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic")} {
|
||||
if history := fmt.Sprintf("%#v", mounted.Metadata().History(path)); strings.Contains(history, "CANARY") {
|
||||
t.Fatalf("metadata history at %s exposed a canary: %s", path, history)
|
||||
}
|
||||
}
|
||||
|
||||
var authoredCalls calls
|
||||
destinationDecodeProbeValues = nil
|
||||
authored, err := newLoad(false, &authoredCalls)
|
||||
if err != nil {
|
||||
t.Fatalf("explicitly wrapped load: %v", err)
|
||||
}
|
||||
if authoredCalls != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
|
||||
t.Fatalf("authored lifecycle counts = %#v", authoredCalls)
|
||||
}
|
||||
sawFinalDecode = false
|
||||
for _, value := range destinationDecodeProbeValues {
|
||||
if value == decodeValue {
|
||||
sawFinalDecode = true
|
||||
} else if value != "" {
|
||||
t.Fatalf("authored decoder observed a non-final value %q: %#v", value, destinationDecodeProbeValues)
|
||||
}
|
||||
}
|
||||
if !sawFinalDecode {
|
||||
t.Fatal("final authored value was not decoded")
|
||||
}
|
||||
if !reflect.DeepEqual(mounted.Config, authored.Config) {
|
||||
t.Fatalf("mounted config %#v differs from explicitly wrapped config %#v", mounted.Config, authored.Config)
|
||||
}
|
||||
differences, err := Diff(mounted.Inspection, authored.Inspection)
|
||||
if err != nil || len(differences) != 0 {
|
||||
t.Fatalf("mounted versus authored diff = %#v, %v", differences, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Tenant struct {
|
||||
Dynamic string `yaml:"dynamic"`
|
||||
Derived string `yaml:"derived"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
load := func(value string, classify bool) *testResult[config] {
|
||||
t.Helper()
|
||||
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
|
||||
main: loadInput{source: Bytes("input", []byte("dynamic: "+value+"\n")), destination: Keys("tenant")},
|
||||
definition: Definition[config]{
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic"))
|
||||
return nil
|
||||
},
|
||||
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },
|
||||
},
|
||||
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
return testLoaded(loaded)
|
||||
}
|
||||
left := load("LEFT-DIFF-CANARY", true)
|
||||
right := load("RIGHT-DIFF-CANARY", false)
|
||||
differences, err := Diff(left.Inspection, right.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 2 {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, difference := range differences {
|
||||
path := difference.Path.String()
|
||||
seen[path] = true
|
||||
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged ||
|
||||
strings.Contains(difference.Left.YAML+difference.Right.YAML, "DIFF-CANARY") {
|
||||
t.Errorf("bilaterally classified difference at %s = %#v", path, difference)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/tenant/dynamic", "/tenant/derived"} {
|
||||
if !seen[path] || !left.Inspection.secrets[path] || right.Inspection.secrets[path] {
|
||||
t.Errorf("classification/difference at %s: seen=%v left=%v right=%v", path, seen[path], left.Inspection.secrets[path], right.Inspection.secrets[path])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
calls := struct{ defaults, resolve, lookup, validate int }{}
|
||||
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
|
||||
main: loadInput{source: Bytes("invalid", []byte("count: not-an-integer\n")), destination: Keys("tenant")},
|
||||
definition: Definition[config]{
|
||||
Defaults: func() config { calls.defaults++; return config{} },
|
||||
Resolve: func(*config, *Resolution) error { calls.resolve++; return nil },
|
||||
Validate: func(*config, *Validation) { calls.validate++ },
|
||||
},
|
||||
}, testSecretBinding(), func(string) (string, bool) { calls.lookup++; return "", false })
|
||||
if loaded != nil || err == nil {
|
||||
t.Fatalf("mounted decode failure returned a usable result: %#v, %v", loaded, err)
|
||||
}
|
||||
if calls.defaults != 1 || calls.resolve != 0 || calls.lookup != 0 || calls.validate != 0 {
|
||||
t.Fatalf("callbacks after mounted decode failure = %#v", calls)
|
||||
}
|
||||
if got := groupedErrorCode(t, err); got != CodeDecodeType {
|
||||
t.Fatalf("mounted decode failure code = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func indentDestinationBody(body string) string {
|
||||
var result strings.Builder
|
||||
for _, line := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
||||
result.WriteString(" ")
|
||||
result.WriteString(line)
|
||||
result.WriteByte('\n')
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
118
internal/engine/destination_load_test.go
Normal file
118
internal/engine/destination_load_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type destinationLoadConfig struct {
|
||||
Main map[string]string `yaml:"main"`
|
||||
Defaults map[string]string `yaml:"defaults"`
|
||||
Collections map[string]map[string]string `yaml:"collections"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
func TestLayeredLoadPlacesNonImportMainAndUnderlayInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
destination := Keys("main")
|
||||
main := Input{Source: Bytes("main", []byte("value: primary\n")), Destination: destination}
|
||||
underlay := Input{Source: Bytes("defaults", []byte("value: fallback\n")), Destination: Keys("defaults")}
|
||||
loaded, err := LoadLayered(main, testSecretBinding(), LayeredOptions[destinationLoadConfig]{
|
||||
Underlays: []Underlay{{Inputs: []Input{underlay}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if result.Config.Main["value"] != "primary" || result.Config.Defaults["value"] != "fallback" {
|
||||
t.Fatalf("mounted configuration = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/main" || sources[1].Destination.String() != "/defaults" {
|
||||
t.Fatalf("source destinations = %#v", sources)
|
||||
}
|
||||
|
||||
destination.segments[0].key = "caller mutation"
|
||||
sources[0].Destination.segments[0].key = "record mutation"
|
||||
if got := result.Sources()[0].Destination.String(); got != "/main" {
|
||||
t.Fatalf("source destination was not independently owned: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadOmittedAndEmptyDestinationsKeepRootPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inputs := []Input{
|
||||
{Source: Bytes("omitted", []byte("value: same\n"))},
|
||||
{Source: Bytes("empty", []byte("value: same\n")), Destination: Keys()},
|
||||
}
|
||||
for _, input := range inputs {
|
||||
loaded, err := LoadLayered(input, testSecretBinding(), LayeredOptions[destinationLoadConfig]{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if result.Config.Value != "same" {
|
||||
t.Fatalf("root value = %q", result.Config.Value)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != "" {
|
||||
t.Fatalf("root source destination = %#v", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadDistinguishesEmptyBodyFromNoSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource(), Destination: Keys("absent")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("empty", []byte("{}\n")), Destination: Keys("collections", "one"),
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
info, ok := result.Metadata().Lookup(Keys("collections", "one"))
|
||||
if !ok || !info.Explicit || info.Origin.Layer != LayerUnderlay {
|
||||
t.Fatalf("empty mounted mapping = %#v, %v", info, ok)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("absent")); ok {
|
||||
t.Fatal("NoSource materialized its destination")
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Label != "empty" || sources[0].Destination.String() != "/collections/one" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadPreflightsDestinationsBeforeReadingSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := bytes.NewBufferString("value: unused\n")
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Reader("reader", reader), Destination: Keys("configkit"),
|
||||
}}}}},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("reserved destination was accepted")
|
||||
}
|
||||
if reader.Len() == 0 {
|
||||
t.Fatal("source reader was consumed before destination preflight")
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
|
||||
t.Fatalf("destination diagnostic = %#v", diagnostic)
|
||||
}
|
||||
}
|
||||
181
internal/engine/destination_provenance_test.go
Normal file
181
internal/engine/destination_provenance_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
sharedPath := writeImportFile(t, directory, "shared.yml", "section:\n shared: lower\n branch:\n child: old\n kept: retained\n")
|
||||
shared := File(sharedPath)
|
||||
load := func() (*Loaded[map[string]any], error) {
|
||||
return LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{
|
||||
{Inputs: []Input{
|
||||
{Source: shared, Destination: Keys("catalog")},
|
||||
{Source: Bytes("peer", []byte("section:\n peer: present\n")), Destination: Keys("catalog")},
|
||||
}},
|
||||
{Inputs: []Input{{Source: shared, Destination: Keys("backup")}}},
|
||||
{Inputs: []Input{{Source: Bytes("replacement", []byte("section:\n branch: replaced\n")), Destination: Keys("catalog")}}},
|
||||
}},
|
||||
)
|
||||
}
|
||||
loaded, err := load()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
metadata := result.Metadata()
|
||||
|
||||
container, ok := metadata.Lookup(Keys("catalog"))
|
||||
if !ok || !container.Explicit || len(container.Origin.Locations) < 3 {
|
||||
t.Fatalf("mounted container origin = %#v, %v", container, ok)
|
||||
}
|
||||
containerSources := make(map[string]bool)
|
||||
for _, location := range container.Origin.Locations {
|
||||
containerSources[location.SourceID] = true
|
||||
if location.Line < 1 {
|
||||
t.Fatalf("introduced container lacks its source-root fallback: %#v", location)
|
||||
}
|
||||
}
|
||||
for _, sourceID := range []string{"source-1", "source-2", "source-4"} {
|
||||
if !containerSources[sourceID] {
|
||||
t.Fatalf("container contributors = %#v, missing %s", container.Origin.Locations, sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
sharedField, ok := metadata.Lookup(Keys("catalog", "section", "shared"))
|
||||
if !ok || len(sharedField.Origin.Locations) != 1 || sharedField.Origin.Locations[0].SourceID != "source-1" || sharedField.Origin.Locations[0].Line != 2 {
|
||||
t.Fatalf("authored leaf origin = %#v, %v", sharedField, ok)
|
||||
}
|
||||
peerField, ok := metadata.Lookup(Keys("catalog", "section", "peer"))
|
||||
if !ok || len(peerField.Origin.Locations) != 1 || peerField.Origin.Locations[0].SourceID != "source-2" {
|
||||
t.Fatalf("peer leaf origin = %#v, %v", peerField, ok)
|
||||
}
|
||||
if _, ok := metadata.Lookup(Keys("catalog", "section", "branch", "child")); ok {
|
||||
t.Fatal("replaced descendant remains effective")
|
||||
}
|
||||
removed := metadata.History(Keys("catalog", "section", "branch", "child"))
|
||||
if len(removed) < 2 || removed[0].Origin.Locations[0].SourceID != "source-1" {
|
||||
t.Fatalf("removed descendant history = %#v", removed)
|
||||
}
|
||||
|
||||
backup, ok := metadata.Lookup(Keys("backup", "section", "shared"))
|
||||
if !ok || len(backup.Origin.Locations) != 1 || backup.Origin.Locations[0].SourceID != "source-3" {
|
||||
t.Fatalf("second-tier leaf origin = %#v, %v", backup, ok)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 4 || sources[0].Path != sources[2].Path || sources[0].Destination.String() != "/catalog" || sources[2].Destination.String() != "/backup" {
|
||||
t.Fatalf("same source in separate tiers = %#v", sources)
|
||||
}
|
||||
if sources[0].Layer != LayerUnderlay || sources[0].UnderlayIndex != 1 || sources[2].Layer != LayerUnderlay || sources[2].UnderlayIndex != 2 {
|
||||
t.Fatalf("tier source records = %#v", sources)
|
||||
}
|
||||
|
||||
other, err := load()
|
||||
if err != nil {
|
||||
t.Fatalf("independent load: %v", err)
|
||||
}
|
||||
differences, err := Diff(loaded.Inspection, other.Inspection)
|
||||
if err != nil || len(differences) != 0 {
|
||||
t.Fatalf("equivalent inspection diff = %#v, %v", differences, err)
|
||||
}
|
||||
|
||||
sources[0].Destination.segments[0].key = "mutated"
|
||||
sharedField.Origin.Locations[0].SourceID = "mutated"
|
||||
removed[0].Path.segments[0].key = "mutated"
|
||||
if got := result.Sources()[0].Destination.String(); got != "/catalog" {
|
||||
t.Fatalf("source record copy changed after inspection: %q", got)
|
||||
}
|
||||
fresh, ok := result.Metadata().Lookup(Keys("catalog", "section", "shared"))
|
||||
if !ok || fresh.Origin.Locations[0].SourceID != "source-1" {
|
||||
t.Fatalf("effective origin changed after mutating returned records: %#v, %v", fresh, ok)
|
||||
}
|
||||
if got := result.Metadata().History(Keys("catalog", "section", "branch", "child")); len(got) < 2 || got[0].Path.String() != "/catalog/section/branch/child" {
|
||||
t.Fatalf("historical path changed after mutating returned records: %#v", got)
|
||||
}
|
||||
if got := other.Inspection.sources[0].Destination.String(); got != "/catalog" {
|
||||
t.Fatalf("independent load destination changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredImportedInheritedValuesRetainResourceContexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type entry struct {
|
||||
Extends string `yaml:"extends"`
|
||||
Resource string `yaml:"resource"`
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
type config struct {
|
||||
Catalog struct {
|
||||
Entries map[string]entry `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config/main.yml", "child:\n extends: parent\n")
|
||||
inputPath := writeImportFile(t, directory, "shared/input.yml", "configkit: {imports: [{path: parent.yml}]}\n")
|
||||
writeImportFile(t, directory, "shared/parent.yml", "parent:\n resource: icon.txt\n count: 1\n")
|
||||
writeImportFile(t, directory, "shared/icon.txt", "parent-resource")
|
||||
var resolutionContext ResourceContext
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Destination: Keys("catalog", "entries")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(inputPath), Imports: true, Destination: Keys("catalog", "entries"),
|
||||
}}}},
|
||||
Definition: Definition[config]{Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
info, ok := resolution.InputInfo(Keys("catalog", "entries", "child", "resource"))
|
||||
if !ok || len(info.Origin.Locations) != 1 || len(info.Origin.Inheritance) != 1 {
|
||||
return errors.New("inherited resource lacks its source origin")
|
||||
}
|
||||
var found bool
|
||||
resolutionContext, found = resolution.state.input.sourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found || resolutionContext.BaseDir != "." || resolutionContext.FS == nil {
|
||||
return errors.New("resolver could not find the declaring resource context")
|
||||
}
|
||||
data, err := fs.ReadFile(resolutionContext.FS, path.Join(resolutionContext.BaseDir, configuration.Catalog.Entries["child"].Resource))
|
||||
if err != nil || string(data) != "parent-resource" {
|
||||
return errors.New("resolver could not read the inherited relative resource")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
child := result.Config.Catalog.Entries["child"]
|
||||
if child.Resource != "icon.txt" || child.Count != 1 || child.Extends != "" {
|
||||
t.Fatalf("inherited child = %#v", child)
|
||||
}
|
||||
field, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", "resource"))
|
||||
if !ok || len(field.Origin.Locations) != 1 || len(field.Origin.Inheritance) != 1 {
|
||||
t.Fatalf("inherited resource origin = %#v, %v", field, ok)
|
||||
}
|
||||
context, ok := result.Inspection.SourceContext(field.Origin.Locations[0].SourceID)
|
||||
if !ok || context.BaseDir != "." || context.FS == nil || resolutionContext.BaseDir != context.BaseDir {
|
||||
t.Fatalf("completed resource contexts = %#v, %#v, %v", resolutionContext, context, ok)
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child.Resource))
|
||||
if err != nil || string(data) != "parent-resource" {
|
||||
t.Fatalf("completed source-relative resource = %q, %v", data, err)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Config.Catalog.Entries["parent"], entry{Resource: "icon.txt", Count: 1}) {
|
||||
t.Fatalf("surviving lower-tier parent = %#v", result.Config.Catalog.Entries["parent"])
|
||||
}
|
||||
for _, source := range result.Sources() {
|
||||
if source.Layer == LayerUnderlay && source.Destination.String() != "/catalog/entries" {
|
||||
t.Errorf("underlay destination = %#v", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
178
internal/engine/destination_test.go
Normal file
178
internal/engine/destination_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parsed, err := ParsePath("/a~1b/0/~0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalized, err := normalizeDestination(parsed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := normalized.String(), "/a~1b/0/~0"; got != want {
|
||||
t.Fatalf("normalized path = %q, want %q", got, want)
|
||||
}
|
||||
for index, want := range []string{"a/b", "0", "~"} {
|
||||
segment := normalized.segments[index]
|
||||
if segment.kind != pathSegmentKey || segment.key != want {
|
||||
t.Fatalf("segment %d = %#v, want key %q", index, segment, want)
|
||||
}
|
||||
}
|
||||
|
||||
original := Keys("owned", "path")
|
||||
owned, err := normalizeDestination(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned.segments[0].key = "changed"
|
||||
if got := original.String(); got != "/owned/path" {
|
||||
t.Fatalf("normalizing shared path storage mutated its input: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, path := range map[string]Path{
|
||||
"explicit index": Keys("items").Index(0),
|
||||
"negative index": Keys("items").Index(-1),
|
||||
"invalid path": Path{invalid: true},
|
||||
"configkit": Keys("configkit"),
|
||||
"extends": Keys("extends"),
|
||||
"nested configkit": Keys("outer", "configkit"),
|
||||
"nested extends": Keys("outer", "extends"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := normalizeDestination(path); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("normalizeDestination(%q) error = %v, want invalid destination", path.String(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, key := range []string{"myextends", "configkit2", "", ".", "..", "../x", "a.b", "a/b", "~"} {
|
||||
t.Run("ordinary key "+key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := normalizeDestination(Keys(key)); err != nil {
|
||||
t.Fatalf("normalizeDestination(%q): %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootLocation := document.Location{SourceID: "source-a", Line: 1, Column: 1}
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Location: document.Location{SourceID: "source-a", Line: 4, Column: 1},
|
||||
}
|
||||
path, placed, err := placeDocument(body, Keys("a", "b"), rootLocation, 2, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := path.String(), "/a/b"; got != want {
|
||||
t.Fatalf("normalized destination = %q, want %q", got, want)
|
||||
}
|
||||
if placed == body || len(placed.Mapping) != 1 || placed.Mapping[0].Key != "a" {
|
||||
t.Fatalf("outer destination mapping was not created: %#v", placed)
|
||||
}
|
||||
inner := placed.Mapping[0].Value
|
||||
if inner.Kind != document.Mapping || len(inner.Mapping) != 1 || inner.Mapping[0].Key != "b" {
|
||||
t.Fatalf("inner destination mapping was not created: %#v", inner)
|
||||
}
|
||||
if inner.Mapping[0].Value != body || len(body.Mapping) != 0 {
|
||||
t.Fatal("placement mutated or copied the authored body")
|
||||
}
|
||||
if placed.Location != rootLocation || inner.Location != rootLocation || placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
|
||||
t.Fatal("introduced structure did not use the source-root coordinate fallback")
|
||||
}
|
||||
if body.Location != (document.Location{SourceID: "source-a", Line: 4, Column: 1}) {
|
||||
t.Fatalf("authored body location changed: %#v", body.Location)
|
||||
}
|
||||
if got, want := countInputNodes(placed), countInputNodes(body)+4; got != want {
|
||||
t.Fatalf("placed node count = %d, want body count plus four synthetic nodes (%d)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentPreservesAuthoredEntryCoordinates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootLocation := document.Location{SourceID: "source-b", Line: 1, Column: 1}
|
||||
bodyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 1}
|
||||
keyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 3}
|
||||
valueLocation := document.Location{SourceID: "source-b", Line: 4, Column: 5}
|
||||
value := &document.Node{Kind: document.String, Location: valueLocation, Text: "value"}
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Location: bodyLocation,
|
||||
Mapping: []document.Entry{{
|
||||
Key: "leaf",
|
||||
KeyLocation: keyLocation,
|
||||
ValueLocation: valueLocation,
|
||||
Value: value,
|
||||
}},
|
||||
}
|
||||
_, placed, err := placeDocument(body, Keys("mounted"), rootLocation, 2, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
|
||||
t.Fatal("introduced destination entry did not use the source-root fallback")
|
||||
}
|
||||
if placed.Mapping[0].Value != body || body.Location != bodyLocation {
|
||||
t.Fatal("placement changed the authored body node or location")
|
||||
}
|
||||
entry := body.Mapping[0]
|
||||
if entry.KeyLocation != keyLocation || entry.ValueLocation != valueLocation || entry.Value != value || value.Location != valueLocation {
|
||||
t.Fatal("placement changed authored entry or value coordinates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentRootAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Mapping: []document.Entry{{
|
||||
Key: "leaf",
|
||||
Value: &document.Node{Kind: document.String, Text: "value"},
|
||||
}},
|
||||
}
|
||||
path, placed, err := placeDocument(body, Path{}, body.Location, 1, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path.String() != "" || placed != body {
|
||||
t.Fatal("root placement must preserve the existing body")
|
||||
}
|
||||
|
||||
destination := Keys("a", "b")
|
||||
if !destinationWithinLimits(body, destination, 3, 7) {
|
||||
t.Fatal("exact depth and node limits should admit the placed document")
|
||||
}
|
||||
if destinationWithinLimits(body, destination, 2, 7) {
|
||||
t.Fatal("placement exceeding the depth limit was accepted")
|
||||
}
|
||||
if destinationWithinLimits(body, destination, 3, 6) {
|
||||
t.Fatal("placement exceeding the node limit was accepted")
|
||||
}
|
||||
_, placed, err = placeDocument(body, destination, body.Location, 3, 6)
|
||||
if !errors.Is(err, errDestinationLimit) || placed != nil {
|
||||
t.Fatalf("bounded placement returned node %p and error %v", placed, err)
|
||||
}
|
||||
_, absent, err := placeDocument(nil, destination, document.Location{}, 0, 0)
|
||||
if err != nil || absent != nil {
|
||||
t.Fatalf("absent body should not consume placement limits: node %p, error %v", absent, err)
|
||||
}
|
||||
}
|
||||
347
internal/engine/destination_tiers_test.go
Normal file
347
internal/engine/destination_tiers_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationStrictCompositionBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputs []Input
|
||||
wantPath []string
|
||||
want any
|
||||
alsoPath []string
|
||||
alsoWant any
|
||||
}{
|
||||
{
|
||||
name: "disjoint peers at one destination",
|
||||
inputs: []Input{
|
||||
{Source: Bytes("left", []byte("left: one\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte("right: two\n")), Destination: Keys("application")},
|
||||
},
|
||||
wantPath: []string{"application", "right"},
|
||||
want: "two",
|
||||
alsoPath: []string{"application", "left"},
|
||||
alsoWant: "one",
|
||||
},
|
||||
{
|
||||
name: "different destinations",
|
||||
inputs: []Input{
|
||||
{Source: Bytes("left", []byte("value: shared\n")), Destination: Keys("left")},
|
||||
{Source: Bytes("right", []byte("value: shared\n")), Destination: Keys("right")},
|
||||
},
|
||||
wantPath: []string{"right", "value"},
|
||||
want: "shared",
|
||||
},
|
||||
{
|
||||
name: "ancestor and descendant destinations",
|
||||
inputs: []Input{
|
||||
{Source: Bytes("parent", []byte("branch: {base: one}\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("child", []byte("leaf: two\n")), Destination: Keys("application", "branch")},
|
||||
},
|
||||
wantPath: []string{"application", "branch", "leaf"},
|
||||
want: "two",
|
||||
alsoPath: []string{"application", "branch", "base"},
|
||||
alsoWant: "one",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: test.inputs}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := layeredMapValue(t, result.Config, test.wantPath...); got != test.want {
|
||||
t.Fatalf("value at %v = %#v, want %#v", test.wantPath, got, test.want)
|
||||
}
|
||||
if len(test.alsoPath) != 0 {
|
||||
if got := layeredMapValue(t, result.Config, test.alsoPath...); got != test.alsoWant {
|
||||
t.Fatalf("value at %v = %#v, want %#v", test.alsoPath, got, test.alsoWant)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{name: "equal values conflict", left: "value: same\n", right: "value: same\n"},
|
||||
{name: "unequal values conflict", left: "value: left\n", right: "value: right\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs := []Input{
|
||||
{Source: Bytes("left", []byte(test.left)), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte(test.right)), Destination: Keys("application")},
|
||||
}
|
||||
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: inputs}},
|
||||
})
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("strict composition result = %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("strict conflict is not hidden by an upper override", func(t *testing.T) {
|
||||
inputs := []Input{
|
||||
{Source: Bytes("left", []byte("value: left\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte("value: right\n")), Destination: Keys("application")},
|
||||
}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("value: upper\n")), Destination: Keys("application")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: inputs}}},
|
||||
)
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("strict composition result = %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLayeredDestinationMatchesExplicitlyWrappedInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
load := func(input Input) map[string]any {
|
||||
t.Helper()
|
||||
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: []Input{input}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
return testLoaded(loaded).Config
|
||||
}
|
||||
|
||||
placed := load(Input{Source: Bytes("placed", []byte("field: value\n")), Destination: Keys("application")})
|
||||
authored := load(Input{Source: Bytes("authored", []byte("application: {field: value}\n"))})
|
||||
if !reflect.DeepEqual(placed, authored) {
|
||||
t.Fatalf("placed configuration %#v differs from explicitly wrapped %#v", placed, authored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyMountedMappingRetainsLowerChildren(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
destination := Keys("collections", "weather")
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("empty-main", []byte("{}\n")), Destination: destination},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("lower", []byte("prompt: {title: retained}\n")), Destination: destination,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if got := layeredMapValue(t, testLoaded(loaded).Config, "collections", "weather", "prompt", "title"); got != "retained" {
|
||||
t.Fatalf("lower child beneath empty mounted mapping = %#v, want retained", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"overlays/test.yml": &fstest.MapFile{Data: []byte("settings:\n metadata:\n version: overlay\n")},
|
||||
})
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("mapping: {shared: main}\nsequence: [9]\ncleared: null\nenabled: false\nmetadata: {version: main}\n")), Destination: Keys("settings")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("underlay", []byte("mapping: {retained: lower, shared: lower}\nsequence: [1, 2]\ncleared: {old: true}\nenabled: true\ncount: 7\nmetadata: {version: lower, label: retained}\n")),
|
||||
Destination: Keys("settings"),
|
||||
}}}},
|
||||
Overlay: "test",
|
||||
OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
Overrides: []Override{{Path: Keys("settings", "count"), Value: 0, Flag: "--count"}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
settings := layeredMap(t, result.Config, "settings")
|
||||
mapping := layeredMap(t, settings, "mapping")
|
||||
if mapping["retained"] != "lower" || mapping["shared"] != "main" {
|
||||
t.Fatalf("recursive mapping overlay = %#v", mapping)
|
||||
}
|
||||
sequence, ok := settings["sequence"].([]any)
|
||||
if !ok || len(sequence) != 1 || sequence[0] != int64(9) {
|
||||
t.Fatalf("sequence overlay = %#v", settings["sequence"])
|
||||
}
|
||||
if value, present := settings["cleared"]; !present || value != nil {
|
||||
t.Fatalf("explicit null = %#v, present %v", value, present)
|
||||
}
|
||||
if value, ok := settings["enabled"].(bool); !ok || value {
|
||||
t.Fatalf("explicit false = %#v", settings["enabled"])
|
||||
}
|
||||
if value, ok := settings["count"].(int64); !ok || value != 0 {
|
||||
t.Fatalf("explicit zero override = %#v", settings["count"])
|
||||
}
|
||||
metadata := layeredMap(t, settings, "metadata")
|
||||
if metadata["version"] != "overlay" || metadata["label"] != "retained" {
|
||||
t.Fatalf("version-only overlay = %#v", metadata)
|
||||
}
|
||||
if _, present := settings["settings"]; present {
|
||||
t.Fatalf("upper tiers were incorrectly nested under the main destination: %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationChecksVersionOnceOnComposedMain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type appConfig struct {
|
||||
Version int8 `yaml:"version"`
|
||||
Count int `yaml:"count"`
|
||||
Main string `yaml:"main"`
|
||||
Imported string `yaml:"imported"`
|
||||
Lower string `yaml:"lower"`
|
||||
}
|
||||
type config struct {
|
||||
App appConfig `yaml:"app"`
|
||||
}
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\nversion: 6\ncount: 4\nmain: main\n")},
|
||||
"fragment.yml": &fstest.MapFile{Data: []byte("imported: included\n")},
|
||||
})
|
||||
checks := 0
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("app")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{
|
||||
Definition: Definition[config]{Version: &VersionRule{Path: Keys("app", "version"), Check: func(version Version, _ *Validation) error {
|
||||
checks++
|
||||
if !version.Present || version.Text != "6" {
|
||||
t.Errorf("nested main version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("lower", []byte("lower: underlay\n")), Destination: Keys("app"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if checks != 1 {
|
||||
t.Fatalf("version hook calls = %d, want 1", checks)
|
||||
}
|
||||
if result.Config.App.Version != 6 || result.Config.App.Count != 4 || result.Config.App.Main != "main" || result.Config.App.Imported != "included" || result.Config.App.Lower != "underlay" {
|
||||
t.Fatalf("nested app = %#v", result.Config.App)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rule := &VersionRule{Path: Keys("app", "schema", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode Code
|
||||
wantValue any
|
||||
}{
|
||||
{name: "underlay direct claim", body: "schema: {version: v2}\n", wantCode: CodeUnderlayVersion},
|
||||
{name: "underlay ancestor claim", body: "schema: null\n", wantCode: CodeUnderlayVersion},
|
||||
{name: "underlay mapping ancestor without claim", body: "schema: {name: ordinary}\n", wantValue: "ordinary"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("underlay", []byte(test.body)), Destination: Keys("app"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if test.wantCode != "" {
|
||||
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
|
||||
t.Fatalf("version claim result = %#v, %v", loaded, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("nonclaiming mapping ancestor was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != test.wantValue {
|
||||
t.Fatalf("nested ordinary value = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode Code
|
||||
}{
|
||||
{name: "imported direct claim", body: "schema: {version: v2}\n", wantCode: CodeImportVersion},
|
||||
{name: "imported ancestor claim", body: "schema: null\n", wantCode: CodeImportVersion},
|
||||
{name: "imported mapping ancestor without claim", body: "schema: {name: ordinary}\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", test.body)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("app"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if test.wantCode != "" {
|
||||
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
|
||||
t.Fatalf("version claim result = %#v, %v", loaded, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("nonclaiming imported mapping ancestor was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != "ordinary" {
|
||||
t.Fatalf("nested ordinary value = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func layeredMap(t *testing.T, value map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := value[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func layeredMapValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||
t.Helper()
|
||||
for _, key := range keys[:len(keys)-1] {
|
||||
value = layeredMap(t, value, key)
|
||||
}
|
||||
return value[keys[len(keys)-1]]
|
||||
}
|
||||
@@ -45,10 +45,11 @@ type importAccounting struct {
|
||||
}
|
||||
|
||||
type importTraversal struct {
|
||||
access *filesystemAccess
|
||||
accounting *importAccounting
|
||||
limits importLimits
|
||||
rule *VersionRule
|
||||
access *filesystemAccess
|
||||
accounting *importAccounting
|
||||
limits importLimits
|
||||
rule *VersionRule
|
||||
destination Path
|
||||
|
||||
documents []*document.Node
|
||||
sources []SourceInfo
|
||||
@@ -58,16 +59,16 @@ type importTraversal struct {
|
||||
}
|
||||
|
||||
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
return loadImportedDocumentsTracked(source, context, main, rule, limits, &importAccounting{}, nil, nil)
|
||||
return loadImportedDocumentsTracked(source, context, main, Path{}, rule, limits, &importAccounting{}, nil, nil)
|
||||
}
|
||||
|
||||
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
if nativeContext(source, context) {
|
||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||
if diagnostic != nil {
|
||||
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
|
||||
}
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, rule, limits, accounting, visited)
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
||||
}
|
||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
@@ -82,14 +83,14 @@ func loadImportedDocumentsTracked(source Source, context *FilesystemContext, mai
|
||||
if hostToken != nil {
|
||||
access.token = hostToken
|
||||
}
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, rule, limits, accounting, visited)
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
||||
}
|
||||
|
||||
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, rule, limits, accounting, nil)
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, Path{}, rule, limits, accounting, nil)
|
||||
}
|
||||
|
||||
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
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)}
|
||||
@@ -102,6 +103,7 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
||||
}
|
||||
}
|
||||
mainInfo := cloneSourceInfo(*main.source)
|
||||
mainInfo.Destination = destination.clone()
|
||||
if mainPath != "" {
|
||||
mainInfo.Path = mainPath
|
||||
}
|
||||
@@ -118,9 +120,10 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
||||
accounting.nodes += countInputNodes(main.document)
|
||||
state := importTraversal{
|
||||
access: access, accounting: accounting, limits: limits, rule: rule,
|
||||
documents: make([]*document.Node, 0, 1),
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int), visited: visited,
|
||||
destination: destination.clone(),
|
||||
documents: make([]*document.Node, 0, 1),
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int), visited: visited,
|
||||
}
|
||||
if state.visited == nil {
|
||||
state.visited = make(map[resourceIdentity]importRoute)
|
||||
@@ -140,8 +143,17 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
||||
state.stack = append(state.stack, mainRoute)
|
||||
body, declarations, diagnostics := splitImportDirectives(main.document)
|
||||
if len(diagnostics) == 0 {
|
||||
state.documents = append(state.documents, body)
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
||||
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
placementNodes := destinationWrapperNodeCount(state.destination)
|
||||
if err != nil {
|
||||
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
} else if accounting.nodes+placementNodes > limits.nodes {
|
||||
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||
} else {
|
||||
accounting.nodes += placementNodes
|
||||
state.documents = append(state.documents, placed)
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
||||
}
|
||||
}
|
||||
|
||||
closeErr := access.close()
|
||||
@@ -260,7 +272,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
kind = SourceKindFilesystem
|
||||
label = s.access.label + "/" + canonical
|
||||
}
|
||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.destination.clone(), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||
if kind == SourceKindFilesystem {
|
||||
info.namespace = s.access.token
|
||||
}
|
||||
@@ -291,7 +303,16 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
|
||||
_, placed, err := placeDocument(body, s.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
if err != nil {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
|
||||
}
|
||||
placementNodes := destinationWrapperNodeCount(s.destination)
|
||||
if s.accounting.nodes+placementNodes > s.limits.nodes {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||
}
|
||||
s.accounting.nodes += placementNodes
|
||||
if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil {
|
||||
versionLocation := publicLocation(versionNode.Location)
|
||||
return []Diagnostic{{
|
||||
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
||||
@@ -300,7 +321,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
}
|
||||
|
||||
route := importRoute{canonical: canonical, locations: currentRoute.locations}
|
||||
s.documents = append(s.documents, body)
|
||||
s.documents = append(s.documents, placed)
|
||||
s.active[identity] = len(s.stack)
|
||||
s.visited[identity] = route
|
||||
s.stack = append(s.stack, route)
|
||||
|
||||
@@ -41,7 +41,7 @@ func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope)
|
||||
}
|
||||
} else if operation.transfer != nil {
|
||||
if relative, within := relativeHistoryPath(operation.transfer.to, rule.Path); within {
|
||||
_, affected = documentAtPath(operation.parent, relative)
|
||||
affected = fragmentVersionClaim(operation.parent, &VersionRule{Path: relative}) != nil
|
||||
} else if pathContains(rule.Path, operation.transfer.to) {
|
||||
affected = true
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package engine
|
||||
|
||||
import "os"
|
||||
|
||||
// Input selects one source and its independent import policy.
|
||||
// Input selects one source, its destination, and its independent import policy.
|
||||
type Input struct {
|
||||
Source Source
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
Source Source
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
Destination Path
|
||||
}
|
||||
|
||||
// Underlay contains strictly composed contributions at one precedence tier.
|
||||
@@ -32,7 +33,10 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
options = supplied[0]
|
||||
}
|
||||
request := loadRequest[T]{
|
||||
main: loadInput{source: main.Source, imports: main.Imports, filesystem: main.Filesystem},
|
||||
main: loadInput{
|
||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
||||
destination: main.Destination.clone(),
|
||||
},
|
||||
definition: options.Definition, overlay: options.Overlay,
|
||||
overlayFilesystem: options.OverlayFilesystem,
|
||||
overrides: append([]Override(nil), options.Overrides...),
|
||||
@@ -43,6 +47,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
for inputIndex, input := range underlay.Inputs {
|
||||
request.underlays[index].inputs[inputIndex] = loadInput{
|
||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
||||
destination: input.Destination.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
mainOptions.Overlay = request.overlay
|
||||
}
|
||||
|
||||
input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, accounting, nil, nil)
|
||||
input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, request.main.destination, accounting, nil, nil)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
|
||||
@@ -17,10 +17,10 @@ type assembledInput struct {
|
||||
}
|
||||
|
||||
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
||||
return assembleBaseInputTracked(source, options, &importAccounting{}, nil, nil)
|
||||
return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil)
|
||||
}
|
||||
|
||||
func assembleBaseInputTracked[T any](source Source, options Options[T], accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
||||
func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
||||
limits := defaultImportLimits()
|
||||
parseLimits := defaultParseLimits()
|
||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||
@@ -28,16 +28,28 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], accounti
|
||||
input := assembledInput{parsed: parsed, document: parsed.document}
|
||||
if len(diagnostics) != 0 {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
setSourceDestinations(input.sources, destination)
|
||||
// Mapping value issues already identify application content. Other early
|
||||
// failures describe source syntax or access, not a placed value.
|
||||
if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
|
||||
for index := range diagnostics {
|
||||
relative := diagnostics[index].Path
|
||||
segments := clonePathSegments(destination.segments, len(relative.segments))
|
||||
diagnostics[index].Path = Path{segments: append(segments, relative.segments...)}
|
||||
}
|
||||
}
|
||||
return input, diagnostics
|
||||
}
|
||||
if options.Imports {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, options.Definition.Version, limits, accounting, visited, hostToken)
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken)
|
||||
setSourceDestinations(input.sources, destination)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
input.document = input.composition.root
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
setSourceDestinations(input.sources, destination)
|
||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
@@ -48,7 +60,14 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], accounti
|
||||
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+countInputNodes(input.document) > limits.nodes {
|
||||
_, placed, err := placeDocument(input.document, destination, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
|
||||
if err != nil {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
}
|
||||
input.document = placed
|
||||
rawNodes := countInputNodes(parsed.document)
|
||||
placementNodes := destinationWrapperNodeCount(destination)
|
||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||
}
|
||||
if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||
@@ -56,7 +75,7 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], accounti
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += parsed.bytes
|
||||
accounting.nodes += countInputNodes(input.document)
|
||||
accounting.nodes += rawNodes + placementNodes
|
||||
}
|
||||
if !activeOverlay(options.Overlay) || options.Imports {
|
||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
|
||||
|
||||
@@ -17,9 +17,10 @@ type loadRequest[T any] struct {
|
||||
}
|
||||
|
||||
type loadInput struct {
|
||||
source Source
|
||||
imports bool
|
||||
filesystem *FilesystemContext
|
||||
source Source
|
||||
imports bool
|
||||
filesystem *FilesystemContext
|
||||
destination Path
|
||||
}
|
||||
|
||||
type loadGroup struct{ inputs []loadInput }
|
||||
@@ -27,12 +28,23 @@ 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
|
||||
if normalized, err := normalizeDestination(request.main.destination); err != nil {
|
||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(request.main.destination))
|
||||
} else {
|
||||
request.main.destination = normalized
|
||||
}
|
||||
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem,
|
||||
}})
|
||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||
for _, group := range request.underlays {
|
||||
for _, input := range group.inputs {
|
||||
for groupIndex := range request.underlays {
|
||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||
if normalized, err := normalizeDestination(input.destination); err != nil {
|
||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(input.destination))
|
||||
} else {
|
||||
input.destination = normalized
|
||||
}
|
||||
_, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem}})
|
||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||
}
|
||||
@@ -134,7 +146,7 @@ func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, account
|
||||
var contexts map[string]ResourceContext
|
||||
for _, contribution := range group.inputs {
|
||||
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}}
|
||||
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, accounting, visited, hostToken)
|
||||
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, contribution.destination, accounting, visited, hostToken)
|
||||
for _, source := range acquired.sources {
|
||||
source.Layer = LayerUnderlay
|
||||
source.UnderlayIndex = index
|
||||
|
||||
@@ -186,6 +186,7 @@ type SourceInfo struct {
|
||||
ID string
|
||||
Label string
|
||||
Path string
|
||||
Destination Path
|
||||
Layer Layer
|
||||
Kind SourceKind
|
||||
UnderlayIndex int
|
||||
@@ -260,6 +261,7 @@ func cloneLocations(locations []Location) []Location {
|
||||
|
||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
source.Destination = source.Destination.clone()
|
||||
return source
|
||||
}
|
||||
|
||||
|
||||
18
layered.go
18
layered.go
@@ -2,13 +2,17 @@ package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
|
||||
// Input selects one source, whether its imports are enabled, and any explicit
|
||||
// filesystem context. A zero Source is invalid; use NoSource() for an omitted
|
||||
// main input. Sources keep their documented reuse and reader ownership rules.
|
||||
// Input selects one source, its application destination, whether its imports are
|
||||
// enabled, and any explicit filesystem context. A zero Source is invalid; use
|
||||
// NoSource() for an omitted main input. Sources keep their documented reuse and
|
||||
// reader ownership rules.
|
||||
type Input struct {
|
||||
Source Source
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
Source Source
|
||||
// Destination selects the mapping path where Source and its imported
|
||||
// application contributions are placed. Its zero value keeps root placement.
|
||||
Destination Path
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
}
|
||||
|
||||
// Underlay contains inputs that must compose strictly within one tier. Tiers
|
||||
@@ -46,7 +50,7 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T],
|
||||
func toEngineInput(input Input) engine.Input {
|
||||
return engine.Input{
|
||||
Source: input.Source.toEngine(), Imports: input.Imports,
|
||||
Filesystem: toEngineFilesystemContext(input.Filesystem),
|
||||
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
records.go
11
records.go
@@ -182,12 +182,16 @@ type Location struct {
|
||||
// SourceInfo describes a source discovered during a load. Kind identifies its
|
||||
// acquisition, independent of Layer. Path is canonical for a host file and
|
||||
// namespace-relative for a filesystem file; synthetic sources have no Path.
|
||||
// Destination is the effective mapping path where this source contributes.
|
||||
// UnderlayIndex is the one-based original underlay position, or zero for other
|
||||
// sources. ImportedFrom identifies the declaration that included this source.
|
||||
type SourceInfo struct {
|
||||
ID string
|
||||
Label string
|
||||
Path string
|
||||
ID string
|
||||
Label string
|
||||
Path string
|
||||
// Destination is the effective mapping path. Imported sources retain their
|
||||
// input's destination; overlay and CLI sources use the root path.
|
||||
Destination Path
|
||||
Layer Layer
|
||||
Kind SourceKind
|
||||
UnderlayIndex int
|
||||
@@ -260,6 +264,7 @@ func cloneLocations(locations []Location) []Location {
|
||||
}
|
||||
|
||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.Destination = source.Destination.clone()
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
return source
|
||||
}
|
||||
|
||||
@@ -20,3 +20,21 @@ func TestOverlayPublicValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
||||
destination := Keys("collections", "a/b", "0")
|
||||
public := SourceInfo{ID: "source-1", Destination: destination}
|
||||
internal := toEngineSourceInfo(public)
|
||||
if internal.Destination.String() != destination.String() {
|
||||
t.Fatalf("engine destination = %q, want %q", internal.Destination.String(), destination.String())
|
||||
}
|
||||
converted := fromEngineSourceInfo(internal)
|
||||
if converted.Destination.String() != destination.String() {
|
||||
t.Fatalf("public destination = %q, want %q", converted.Destination.String(), destination.String())
|
||||
}
|
||||
copy := cloneSourceInfo(converted)
|
||||
copy.Destination = Path{}
|
||||
if converted.Destination.String() != destination.String() {
|
||||
t.Fatalf("source record destination changed through clone: %q", converted.Destination.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user