Compare commits
39 Commits
315f464dfc
...
e66c37161c
| Author | SHA1 | Date | |
|---|---|---|---|
| e66c37161c | |||
| e4270c9b14 | |||
| e69ad0f32d | |||
| 0a2246bdbe | |||
| e5a6df7af7 | |||
| 25cb3c47e7 | |||
| 020d72084d | |||
| 358b28c421 | |||
| b0550af6a3 | |||
| 43c994a32a | |||
| 1e47ee21cd | |||
| fed591183b | |||
| 1f95cf905a | |||
| 15f5b591b9 | |||
| 184f29c7af | |||
| 813d6a38c9 | |||
| 23ae700194 | |||
| 49218a836b | |||
| 2fcf892324 | |||
| 8645cab79a | |||
| 70c95bf660 | |||
| d9f08ff719 | |||
| 2c91e41d10 | |||
| e3860036f8 | |||
| b3fc6a5950 | |||
| a858a066e4 | |||
| 10edd95d4d | |||
| 08e03f6a38 | |||
| a20ba0af27 | |||
| 6458dbd3aa | |||
| 43e55dfa85 | |||
| 430985c629 | |||
| 4635ef08d5 | |||
| 49c0c6d908 | |||
| a8beb3e620 | |||
| 934c1022e0 | |||
| 7248a8bd95 | |||
| b2db3233b2 | |||
| edb6d05ec1 |
@@ -43,10 +43,30 @@ func toEngineLocations(values []Location) []engine.Location {
|
||||
return result
|
||||
}
|
||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
||||
}
|
||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &SourceVersionCheck{
|
||||
Path: fromEnginePath(value.Path),
|
||||
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||
}
|
||||
}
|
||||
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &engine.SourceVersionCheck{
|
||||
Path: toEnginePath(value.Path),
|
||||
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||
}
|
||||
}
|
||||
func fromEngineOrigin(value engine.Origin) Origin {
|
||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
## 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.
|
||||
Accepted and implemented. Current placement behavior is owned by the
|
||||
[configuration contract](../config.md#input-destinations). The accepted
|
||||
relative-import extension is recorded in
|
||||
[ADR 0012](0012-relative-import-destinations.md). The completed roadmap, consumer
|
||||
request, and review records are preserved at `e4270c9` under `docs/roadmap/` in
|
||||
Git history; the original input-destination implementation plan remains at
|
||||
`315f464:docs/roadmap/implementation.md`.
|
||||
|
||||
## Date
|
||||
|
||||
|
||||
78
docs/adr/0012-relative-import-destinations.md
Normal file
78
docs/adr/0012-relative-import-destinations.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR 0012: Place Imported Configuration at Relative Destinations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#input-destinations) owns current behavior.
|
||||
The completed roadmap and review records are preserved at `e4270c9` under
|
||||
`docs/roadmap/` in Git history; the relative-import implementation plan remains
|
||||
at `d9f08ff:docs/roadmap/implementation.md`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-14
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit can place an input document at a literal mapping path while retaining
|
||||
source identity, composition, and inspection in its normal loading lifecycle.
|
||||
An importing document can currently bring in other documents, but those
|
||||
contributions inherit only the importing input's destination. Consumers that
|
||||
assemble a collection therefore cannot declare the layout of imported
|
||||
fragments alongside the import itself without rewriting source documents or
|
||||
performing another merge outside ConfigKit.
|
||||
|
||||
The extension should reuse input placement and the existing import lifecycle.
|
||||
Logical application placement must remain independent from physical path
|
||||
resolution, source identity, precedence, and resource ownership.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an optional `destination` field to each YAML import declaration. Its value
|
||||
is a sequence of strings representing literal mapping-key segments. Omission
|
||||
and an empty sequence add no segments. A non-empty destination appends to the
|
||||
effective destination of the immediately importing document; nested imports
|
||||
continue from the resulting child destination. Every match of one glob
|
||||
declaration receives the same computed destination.
|
||||
|
||||
Use the existing input-destination validation and reserved-key rules. Do not
|
||||
interpret destination segments as filesystem paths, separators, sequence
|
||||
indices, or root-reset instructions. Physical import paths continue to resolve
|
||||
from their declaring source under existing containment rules.
|
||||
|
||||
Imports remain in their existing strict-composition group. A destination does
|
||||
not grant precedence, bypass cycle or repeated-source rules, or change source
|
||||
identity. Existing placement, version ownership, source records, diagnostics,
|
||||
resource contexts, inheritance, history, and inspection mechanisms continue to
|
||||
govern the placed contributions. No public Go entry point or independent
|
||||
specification-header behavior is introduced.
|
||||
|
||||
This is additive YAML functionality and is minor-release work. Version
|
||||
selection, release notes, roadmap retirement, and publication remain separate
|
||||
release-preparation work.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Require complete application-root destinations on every import.** This
|
||||
avoids inherited context but repeats collection prefixes in nested source
|
||||
declarations and does not express placement relative to the importing
|
||||
document.
|
||||
- **Encode destinations as dotted or slash-delimited strings.** This introduces
|
||||
separator and escaping rules even though existing paths already represent
|
||||
literal mapping keys as segments.
|
||||
- **Rewrite or merge imported documents in consumers.** This duplicates source
|
||||
placement, strict composition, attribution, and inspection behavior outside
|
||||
ConfigKit's loading lifecycle.
|
||||
- **Add independent specification headers or collection-specific validation.**
|
||||
These are separate design questions and are not required to declare relative
|
||||
placement.
|
||||
|
||||
## Consequences
|
||||
|
||||
Import declarations can describe the logical placement of their contributions
|
||||
while preserving the physical source and existing composition lifecycle.
|
||||
Placement is immediate-parent-relative, so nested imports accumulate their
|
||||
declared segments and sibling declarations remain independent. The feature is
|
||||
implemented behind the existing public loading surface; detailed validation,
|
||||
diagnostic, accounting, and inspection requirements are tracked by the
|
||||
implementation roadmap.
|
||||
92
docs/adr/0013-shared-source-version-checks.md
Normal file
92
docs/adr/0013-shared-source-version-checks.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# ADR 0013: Check Source Versions with One Shared Policy
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#shared-source-version-checks) owns current
|
||||
behavior. The completed roadmap, implementation plan, consumer request, and
|
||||
resolved review findings are preserved at `e4270c9` under `docs/roadmap/` in
|
||||
Git history; the [development guide](../development.md#baseline-validation)
|
||||
provides retrieval examples.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-15
|
||||
|
||||
## Context
|
||||
|
||||
Applications that assemble one configuration from independently authored
|
||||
documents need a way to require that each contributing document declares a
|
||||
version accepted by the application's existing `Definition.Version` checker.
|
||||
Checking only the eventual composed value lets another source hide a rejected
|
||||
declaration, while separate consumer-side parsing duplicates ConfigKit's source
|
||||
handling and diagnostics.
|
||||
|
||||
The extension must preserve one application-owned acceptance policy and the
|
||||
existing loading lifecycle. Source declarations are compatibility evidence, not
|
||||
independent fragment schemas or values to compare for equality. Existing
|
||||
composition, placement, precedence, source identity, diagnostics, and
|
||||
sensitivity rules remain authoritative.
|
||||
|
||||
## Decision
|
||||
|
||||
Provide a load-wide source-version mode for ordinary and layered loading. The
|
||||
zero-value mode preserves current behavior. The enabled modes check declarations
|
||||
when present or require them, using the existing `Definition.Version` path and
|
||||
checker. Interpret the configured path relative to each participating source
|
||||
before placement or composition. Different declarations may be accepted by the
|
||||
same checker; ConfigKit adds no equality, SemVer, or expected-version rule.
|
||||
|
||||
Apply the selected policy to the actual main document, direct underlays,
|
||||
imports, and selected overlays. Missing non-main declarations are skipped in
|
||||
if-present mode and rejected in required mode. Existing main absence behavior
|
||||
remains, with required mode additionally rejecting an absent declaration on an
|
||||
actual main document. `NoSource`, unmatched optional inputs, synthetic field
|
||||
operations, and empty groups do not become documents that need declarations.
|
||||
|
||||
Reuse the existing version vocabulary and validation boundary: absence, null,
|
||||
exact strings, and strict decimal integers retain their established meanings;
|
||||
unsupported present values fail version-type validation. Warnings remain
|
||||
nonfatal, and source declarations do not replace final application decoding or
|
||||
domain validation.
|
||||
|
||||
After an accepted non-main declaration is checked, consume exactly that node
|
||||
before its remaining application content is composed or overlaid. Preserve the
|
||||
main declaration and its value semantics. Keep the declaration, source-local
|
||||
path, physical location, and checked absence as detached source evidence,
|
||||
separate from effective application values and history. Apply established
|
||||
sensitivity rules to that evidence, including original mounted paths and
|
||||
ancestors; retain existing safe failure disclosure.
|
||||
|
||||
In enabled modes, the main declaration remains source-local while its protected
|
||||
application path is derived from the main input's destination. This distinction
|
||||
does not change default-mode path interpretation or weaken protection of
|
||||
remaining payload and synthetic operations.
|
||||
|
||||
Public option-field additions and the enabled-mode interpretation for mounted
|
||||
main inputs are compatibility considerations for separately authorized minor-
|
||||
release preparation. This decision does not select a release version or
|
||||
authorize release notes or publication.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Check only the final composed value.** Later sources can hide an earlier
|
||||
rejected declaration, so this does not validate every contributing document.
|
||||
- **Let consumers parse and check each source.** This duplicates acquisition,
|
||||
source attribution, safe diagnostics, and lifecycle coordination in every
|
||||
application.
|
||||
- **Require declarations to be equal or impose a universal version grammar.**
|
||||
Consumers already own the acceptance policy; ConfigKit need not invent an
|
||||
expected value or a domain-specific compatibility rule.
|
||||
- **Keep accepted non-main declarations in application payload.** A selected
|
||||
source-local declaration is metadata for this policy and cannot also remain
|
||||
an ordinary field at the same path without ambiguity.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can apply one existing version policy across contributing documents
|
||||
and select gradual or required adoption per load. Accepted non-main headers no
|
||||
longer participate in application composition; their inspectable evidence is
|
||||
kept separate and subject to sensitivity classification. Default callers retain
|
||||
current behavior. The implementation plan records the concrete API names,
|
||||
integration order, and validation evidence.
|
||||
@@ -160,14 +160,21 @@ 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.
|
||||
Source-root `configkit.imports` directives are processed before placement. An
|
||||
import without an explicit destination inherits the effective destination of
|
||||
its importing document, including through nested imports. An import may instead
|
||||
declare a relative destination as a sequence of literal mapping keys; its keys
|
||||
append to the importing document's effective destination. Omission and `[]`
|
||||
add no keys. Nested imports continue relative to their immediate importing
|
||||
document, each declaration is independent of its siblings, and all matches of
|
||||
one glob receive the same resulting destination. This logical placement does
|
||||
not change how the physical import path resolves from its declaring file.
|
||||
|
||||
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,
|
||||
@@ -179,6 +186,9 @@ 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.
|
||||
The accepted rationale for input placement and its relative-import extension
|
||||
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
|
||||
[ADR 0012](adr/0012-relative-import-destinations.md).
|
||||
|
||||
### Sibling inheritance
|
||||
|
||||
@@ -296,6 +306,67 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
|
||||
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
||||
successful result. Callback panics remain programming failures.
|
||||
|
||||
#### Shared source-version checks
|
||||
|
||||
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
|
||||
one `Definition.Version` policy applies across source documents. The zero value
|
||||
`SourceVersionModeDefault` preserves the existing main-only check at its
|
||||
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
|
||||
checks each participating document that declares a version and permits absent
|
||||
non-main declarations. `SourceVersionModeRequired` rejects a participating
|
||||
document without a declaration. Enabled modes require a usable `VersionRule`
|
||||
and checker; an unknown mode or unusable rule is an options error before source
|
||||
access or callbacks.
|
||||
|
||||
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
|
||||
document's own coordinates, before placement and composition. The main document,
|
||||
direct underlay documents, imported documents (including transitive and glob
|
||||
matches), and the selected overlay participate. `NoSource`, absent optional
|
||||
imports, empty underlay groups, defaults, resolution output, inheritance, and
|
||||
synthetic CLI overrides are not documents to check. An absent declaration in an
|
||||
actual main document still reaches the checker in if-present mode; required mode
|
||||
rejects it first. With `NoSource`, the existing single absent-main checker
|
||||
behavior remains in both enabled modes. If-present omissions in non-main
|
||||
documents skip the checker. Explicit null is present and reaches the checker;
|
||||
unsupported selected values produce `version.type`. No equality with the main
|
||||
declaration or non-null rule is imposed. Acceptance establishes only that the
|
||||
declaration is accepted by the consumer's checker; it does not establish that
|
||||
each fragment conforms to a complete application schema. Final assembled
|
||||
decoding and application validation still run once.
|
||||
|
||||
A header that is absent below a structural control follows the same omission
|
||||
policy. Actual header/control overlap is rejected, and ordinary structural
|
||||
validation and main-version ownership protection continue independently.
|
||||
|
||||
Accepted non-main declarations are consumed from their owned payload before
|
||||
composition; their authored ancestors remain, even if empty. The main
|
||||
declaration remains in the application. Ordinary release metadata should use a
|
||||
different path. Enabled main protection uses the main input's effective path:
|
||||
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
|
||||
the remaining payload against imports, underlays, inheritance, and CLI
|
||||
overrides. Thus a mounted main looks up a local header while ownership checks
|
||||
protect its destination-prefixed application path. Main checker diagnostics and
|
||||
declaration locations use source-local coordinates; ordinary payload
|
||||
diagnostics continue to use effective paths. Default-mode diagnostics are
|
||||
unchanged.
|
||||
|
||||
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
|
||||
occur. Otherwise it records a source-local `Path`, the observed `Version` state
|
||||
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
|
||||
available `Location`, and whether text was redacted. A non-nil record with
|
||||
`Present == false` records observed absence; it does not say the checker ran or
|
||||
accepted a declaration. Absent evidence still identifies the full requested
|
||||
header path, while its location identifies the nearest available source node.
|
||||
During callbacks and in failed-load source records,
|
||||
text is conservatively withheld because final classification may not be
|
||||
available. Successful final source records reflect completed sensitivity
|
||||
classification at the declaration's mounted path and ancestors, including
|
||||
historical classification. Redaction empties `Text` while retaining presence
|
||||
and null state. Returned copies are detached; evidence does not reopen inputs.
|
||||
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
|
||||
decision and the [development guide](development.md#baseline-validation) for
|
||||
the completed implementation and remediation records in Git history.
|
||||
|
||||
### Layered metadata
|
||||
|
||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||
@@ -778,9 +849,13 @@ zero.
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||
imports disabled, any occurrence remains `import.disabled`.
|
||||
string `path` or `glob`, an optional boolean `optional`, and an optional
|
||||
`destination` sequence of strings. Every destination string is one literal
|
||||
mapping key and follows the input-destination validation rules above; quote
|
||||
numeric-looking keys so YAML reads them as strings. Null, scalar destinations,
|
||||
and non-string members are `import.invalid`, as are all other unsupported shapes
|
||||
and fields. A `configkit` key below the root is invalid. With imports disabled,
|
||||
any occurrence remains `import.disabled`.
|
||||
|
||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||
|
||||
@@ -17,9 +17,10 @@ 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 |
|
||||
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, 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 |
|
||||
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
||||
|
||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||
executable examples and ordinary tests. It contains no private application data,
|
||||
@@ -53,6 +54,18 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||
execution profile concepts.
|
||||
|
||||
## Shared source-version checks
|
||||
|
||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||
if-present and required modes with one `Definition.Version` rule across a mounted
|
||||
main input and an underlay. The example reads only sanitized
|
||||
`SourceInfo.VersionCheck` fields; its executable test is in
|
||||
[`layered_test.go`](../../examples/consumers/layered_test.go). The
|
||||
[configuration contract](../config.md#shared-source-version-checks) defines
|
||||
participation, source-local lookup and diagnostics, non-main header consumption,
|
||||
main-path protection, default behavior, and evidence disclosure. Accepted
|
||||
declarations do not replace final assembled decoding or application validation.
|
||||
|
||||
## Effective configuration diffs
|
||||
|
||||
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
|
||||
@@ -109,12 +122,21 @@ and may fail independently of the completed configuration load. The
|
||||
[configuration contract](../config.md#source-resource-contexts) owns exact
|
||||
lifetime and precedence rules.
|
||||
|
||||
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
|
||||
counterpart: its entry opts into imports through `Load`, imports metadata at the
|
||||
root, and places a prompt beneath `prompts` using YAML. The executable example
|
||||
and behavior test are in
|
||||
[`layered_test.go`](../../examples/consumers/layered_test.go).
|
||||
|
||||
## 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.
|
||||
Main and underlay inputs use the same destination field. An import without a
|
||||
`destination` inherits its declaring document's effective destination; an
|
||||
explicit sequence of key strings appends a relative suffix. Nested imports
|
||||
append to their immediate parent's effective destination, while filesystem
|
||||
paths remain relative to the declaring file. Destinations are application
|
||||
mapping paths, 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
|
||||
@@ -123,12 +145,13 @@ 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.
|
||||
embedded collection manifest mounted beneath a literal collection path. The
|
||||
entry's YAML places nested metadata imports at the collection root and prompt
|
||||
and profile peers beneath their own keys. A higher-priority input 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
|
||||
|
||||
@@ -40,6 +40,7 @@ public behavior.
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
||||
| Documentation changes | The owning document |
|
||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
||||
@@ -50,6 +51,7 @@ public behavior.
|
||||
Accepted decisions:
|
||||
|
||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
|
||||
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||
@@ -115,6 +117,22 @@ resolved review findings are preserved in Git history at `1c2610c` under
|
||||
comparison and disclosure behavior, and
|
||||
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
|
||||
|
||||
The completed input-destination, relative-import-destination, and shared
|
||||
source-version roadmaps, associated consumer request, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `e4270c9` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show e4270c9:docs/roadmap/implementation.md
|
||||
git show e4270c9:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Current behavior belongs to the configuration contract's
|
||||
[input destinations](config.md#input-destinations),
|
||||
[imports](config.md#explicit-imports), and
|
||||
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||
Possible follow-ups remain in [deferred scope](roadmap/deferred.md).
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
Acquire declared dependencies explicitly before entering an offline
|
||||
development or validation environment:
|
||||
@@ -161,19 +179,20 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| Task | Behavioral test owners | Focused command |
|
||||
| --- | --- | --- |
|
||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| 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'` |
|
||||
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
||||
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||
| 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'` |
|
||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||
|
||||
@@ -9,8 +9,10 @@ Implemented component owners:
|
||||
|
||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||
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
|
||||
placement and immediate-parent-relative import placement within that
|
||||
assembly, destination-local sibling inheritance before typed decoding, and
|
||||
shared source-version checking with sanitized declaration evidence.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
provenance/history, passive source-context retention, secret resolution and
|
||||
@@ -43,7 +45,8 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
||||
internal types.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||
semantics, including [input destinations](../config.md#input-destinations) and
|
||||
semantics, including [input destinations](../config.md#input-destinations),
|
||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||
The [architecture policy](../policy/architecture.md) owns contributor
|
||||
@@ -53,4 +56,6 @@ 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
|
||||
post-assembly structural operation.
|
||||
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
|
||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||
records the source-version decision.
|
||||
|
||||
@@ -100,11 +100,13 @@ 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;
|
||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||
retains each effective 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.
|
||||
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
|
||||
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
||||
the import extension.
|
||||
|
||||
Library code must not depend on consuming applications or initialize their
|
||||
runtime resources. It must not perform implicit network access, discover
|
||||
|
||||
@@ -39,6 +39,18 @@ Built-in sibling inheritance using `extends` is implemented as described in the
|
||||
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
||||
inheritance between selected overlay files, which remains excluded above.
|
||||
|
||||
## Possible source-version follow-ups
|
||||
|
||||
Independent collection or subtree specification policies were outside the
|
||||
completed shared source-version feature and remain possible future work, with
|
||||
no committed delivery plan. Current [source-version checks](../config.md#shared-source-version-checks)
|
||||
use one application-owned policy across participating documents. Reconsider
|
||||
independent policies if concrete consumer needs justify a separate design
|
||||
decision; this is not a permanent prohibition. The original request and its
|
||||
selected scope remain at `e4270c9:docs/roadmap/promptkit-feature-request.md` in
|
||||
Git history. Existing sequence-composition and cross-scope inheritance deferrals
|
||||
above also remain unchanged.
|
||||
|
||||
## Deferred performance experiments
|
||||
|
||||
The completed performance audit left these internal optimization candidates for
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,319 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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.
|
||||
@@ -43,9 +43,9 @@ type CollectionProfile struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// document owns the application schema version; the mounted entry declares
|
||||
// relative destinations for collection metadata, prompt/profile peers, and
|
||||
// their nested imports. The operator version override remains a separate tier.
|
||||
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
||||
if err != nil {
|
||||
@@ -54,7 +54,6 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||
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]{
|
||||
@@ -69,13 +68,9 @@ func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||
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("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination,
|
||||
}}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
||||
}}},
|
||||
|
||||
@@ -69,22 +69,43 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
||||
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
||||
}
|
||||
var metadataSource configkit.SourceInfo
|
||||
var collectionEntrySource configkit.SourceInfo
|
||||
var versionSource configkit.SourceInfo
|
||||
var parentPromptSource configkit.SourceInfo
|
||||
var promptIndexSource configkit.SourceInfo
|
||||
var profileIndexSource configkit.SourceInfo
|
||||
var promptVersionSource configkit.SourceInfo
|
||||
for _, source := range result.Sources() {
|
||||
switch source.Path {
|
||||
case "collections/weather/collection.yml":
|
||||
collectionEntrySource = source
|
||||
case "collections/weather/metadata.yml":
|
||||
metadataSource = source
|
||||
case "collections/weather/version.yml":
|
||||
versionSource = source
|
||||
case "collections/weather/prompts/index.yml":
|
||||
promptIndexSource = source
|
||||
case "collections/weather/prompts/brief.yml":
|
||||
parentPromptSource = source
|
||||
case "collections/weather/profiles/index.yml":
|
||||
profileIndexSource = 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 collectionEntrySource.ID != "source-2" || collectionEntrySource.Destination.String() != "/collections/weatherreporter" ||
|
||||
metadataSource.ID == "" || metadataSource.Destination.String() != "/collections/weatherreporter" ||
|
||||
metadataSource.ImportedFrom == nil || metadataSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||
versionSource.ID == "" || collectionVersionLocation.SourceID != versionSource.ID || versionSource.Destination.String() != "/collections/weatherreporter" ||
|
||||
versionSource.ImportedFrom == nil || versionSource.ImportedFrom.SourceID != metadataSource.ID || collectionVersionLocation.Line != 1 {
|
||||
t.Fatalf("nested collection version provenance = entry %#v, metadata %#v, version %#v, location %#v", collectionEntrySource, metadataSource, versionSource, collectionVersionLocation)
|
||||
}
|
||||
if promptIndexSource.ID == "" || promptIndexSource.Destination.String() != "/collections/weatherreporter/prompts" ||
|
||||
promptIndexSource.ImportedFrom == nil || promptIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||
profileIndexSource.ID == "" || profileIndexSource.Destination.String() != "/collections/weatherreporter/profiles" ||
|
||||
profileIndexSource.ImportedFrom == nil || profileIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID {
|
||||
t.Fatalf("nested prompt/profile placement = prompt %#v profile %#v", promptIndexSource, profileIndexSource)
|
||||
}
|
||||
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
||||
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
||||
var bundledFiles embed.FS
|
||||
|
||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||
@@ -18,6 +18,30 @@ type BundledConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type PromptBundleConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Prompts map[string]BundlePrompt `yaml:"prompts"`
|
||||
}
|
||||
|
||||
type BundlePrompt struct {
|
||||
Summary string `yaml:"summary"`
|
||||
}
|
||||
|
||||
// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose
|
||||
// destinations are declared relative to the entry document.
|
||||
func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configkit.Load[PromptBundleConfig](
|
||||
configkit.NewFilesystem("prompt bundle", files).File("config.yml"),
|
||||
configkit.Options[PromptBundleConfig]{Imports: true},
|
||||
)
|
||||
}
|
||||
|
||||
// LoadBundled demonstrates a native filesystem source without layered options.
|
||||
func LoadBundled() (*configkit.Result[BundledConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/native-source")
|
||||
|
||||
@@ -20,6 +20,34 @@ func Example_nativeFilesystem() {
|
||||
// Output: embedded false
|
||||
}
|
||||
|
||||
func Example_rootRelativeImports() {
|
||||
result, err := consumers.LoadPromptBundle()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
source := result.Sources()[2]
|
||||
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
|
||||
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
|
||||
}
|
||||
|
||||
func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) {
|
||||
result, err := consumers.LoadPromptBundle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." {
|
||||
t.Fatalf("root import result = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() ||
|
||||
sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() ||
|
||||
sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" ||
|
||||
sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID {
|
||||
t.Fatalf("root import sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func Example_layeredCatalog() {
|
||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||
if err != nil {
|
||||
@@ -32,6 +60,58 @@ func Example_layeredCatalog() {
|
||||
// Output: production false 0 bundle example Welcome from the bundle. <nil>
|
||||
}
|
||||
|
||||
func Example_sourceVersionAdoption() {
|
||||
result, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
checks, redacted := 0, true
|
||||
for _, source := range result.Sources() {
|
||||
if check := source.VersionCheck; check != nil {
|
||||
checks++
|
||||
redacted = redacted && check.Redacted && check.Version.Text == ""
|
||||
}
|
||||
}
|
||||
required, requiredErr := consumers.LoadVersionedService(configkit.SourceVersionModeRequired)
|
||||
fmt.Println(result.Config.Service.Version, result.Config.Service.Metadata.Release,
|
||||
checks, redacted, required != nil && requiredErr == nil)
|
||||
// Output: service-v2 application-2026.10 2 true true
|
||||
}
|
||||
|
||||
func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
|
||||
for _, mode := range []configkit.SourceVersionMode{
|
||||
configkit.SourceVersionModeIfPresent,
|
||||
configkit.SourceVersionModeRequired,
|
||||
} {
|
||||
result, err := consumers.LoadVersionedService(mode)
|
||||
if err != nil || result == nil || result.Config.Service.Version != "service-v2" ||
|
||||
result.Config.Service.Name != "example service" || result.Config.Service.Endpoint != "https://service.example.invalid" ||
|
||||
result.Config.Service.Metadata.Release != "application-2026.10" {
|
||||
t.Fatalf("versioned service mode %d = %#v, %v", mode, result, err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("versioned service sources = %#v", sources)
|
||||
}
|
||||
for _, source := range sources {
|
||||
check := source.VersionCheck
|
||||
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
||||
!check.Redacted || check.Version.Text != "" {
|
||||
t.Fatalf("safe source version evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent,
|
||||
configkit.Override{Path: configkit.Keys("service", "version"), Value: "cli-v3", Flag: "--service-version"})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeOverrideVersion ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/service/version" {
|
||||
t.Fatalf("mounted main version override error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||
if err != nil {
|
||||
|
||||
64
examples/consumers/source_version.go
Normal file
64
examples/consumers/source_version.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// VersionedServiceConfig demonstrates a mounted main input whose source-local
|
||||
// schema version is retained at the effective service path.
|
||||
type VersionedServiceConfig struct {
|
||||
Service struct {
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Metadata struct {
|
||||
Release string `yaml:"release"`
|
||||
} `yaml:"metadata"`
|
||||
} `yaml:"service"`
|
||||
}
|
||||
|
||||
// LoadVersionedService checks the main and underlay declarations with one
|
||||
// application policy. The local rule path is "version" for each source; the
|
||||
// main input's destination places its protected value at /service/version.
|
||||
// Optional CLI overrides are passed through to ConfigKit for normal protection.
|
||||
func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configkit.Override) (*configkit.Result[VersionedServiceConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/source-version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
main, err := fs.ReadFile(files, "main.yml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
underlay, err := fs.ReadFile(files, "underlay.yml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition := configkit.Definition[VersionedServiceConfig]{
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
if !version.Present || version.Null || version.Text == "" {
|
||||
return errors.New("a nonempty source version is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/service/version"
|
||||
},
|
||||
}
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("service main", main), Destination: configkit.Keys("service"),
|
||||
}, configkit.LayeredOptions[VersionedServiceConfig]{
|
||||
Definition: definition,
|
||||
SourceVersionMode: mode,
|
||||
Overrides: overrides,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("service defaults", underlay), Destination: configkit.Keys("service"),
|
||||
}}}},
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
destination: []
|
||||
- path: prompts/index.yml
|
||||
destination: [prompts]
|
||||
- path: profiles/index.yml
|
||||
destination: [profiles]
|
||||
name: Weather Reporter
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
version: "2.4.0"
|
||||
configkit:
|
||||
imports:
|
||||
- path: version.yml
|
||||
description: Imported collection metadata.
|
||||
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: compact.yml
|
||||
- path: weather.yml
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: brief.yml
|
||||
- path: custom.yml
|
||||
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
version: "2.4.0"
|
||||
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
- path: prompts/brief.yml
|
||||
destination: [prompts]
|
||||
name: Root import example
|
||||
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: "1.0"
|
||||
description: Metadata imported into the root document.
|
||||
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
brief:
|
||||
summary: A root-loaded prompt.
|
||||
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
version: service-v2
|
||||
name: example service
|
||||
metadata:
|
||||
release: application-2026.10
|
||||
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: catalog-v1
|
||||
endpoint: https://service.example.invalid
|
||||
@@ -155,6 +155,139 @@ func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write := func(relative, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(directory, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
mainPath := write("main.yml", `configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
destination: [metadata]
|
||||
- glob: prompts/*.yml
|
||||
destination: [prompts]
|
||||
- path: entries/main.yml
|
||||
destination: [profiles]
|
||||
- path: sibling.yml
|
||||
destination: [sibling]
|
||||
root: main
|
||||
`)
|
||||
write("metadata.yml", "name: imported\n")
|
||||
write("prompts/a.yml", "a: {name: alpha}\n")
|
||||
write("prompts/b.yml", "b: {name: beta}\n")
|
||||
write("entries/main.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/details.yml
|
||||
destination: [details]
|
||||
name: profile
|
||||
`)
|
||||
write("entries/nested/details.yml", "summary: nested\n")
|
||||
write("sibling.yml", "name: separate\n")
|
||||
|
||||
root, err := configkit.Load(
|
||||
configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("ordinary root Load: %v, diagnostics %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("ordinary root Load: %v", err)
|
||||
}
|
||||
mountedDestination := configkit.Keys("collections", "weatherreporter")
|
||||
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: mountedDestination,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted layered Load: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
result *configkit.Result[map[string]any]
|
||||
prefix []string
|
||||
}{
|
||||
{name: "ordinary root", result: root},
|
||||
{name: "mounted entry", result: mounted, prefix: []string{"collections", "weatherreporter"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
at := func(keys ...string) []string {
|
||||
return append(append([]string(nil), test.prefix...), keys...)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("root")...); got != "main" {
|
||||
t.Fatalf("root value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("metadata", "name")...); got != "imported" {
|
||||
t.Fatalf("metadata value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "a", "name")...); got != "alpha" {
|
||||
t.Fatalf("first glob value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "b", "name")...); got != "beta" {
|
||||
t.Fatalf("second glob value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "name")...); got != "profile" {
|
||||
t.Fatalf("entry value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "details", "summary")...); got != "nested" {
|
||||
t.Fatalf("nested import value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("sibling", "name")...); got != "separate" {
|
||||
t.Fatalf("subsequent sibling value = %#v", got)
|
||||
}
|
||||
|
||||
wantPaths := []string{
|
||||
"main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml",
|
||||
}
|
||||
wantPhysicalPaths := []string{
|
||||
mainPath,
|
||||
filepath.Join(directory, "metadata.yml"),
|
||||
filepath.Join(directory, "prompts", "a.yml"),
|
||||
filepath.Join(directory, "prompts", "b.yml"),
|
||||
filepath.Join(directory, "entries", "main.yml"),
|
||||
filepath.Join(directory, "entries", "nested", "details.yml"),
|
||||
filepath.Join(directory, "sibling.yml"),
|
||||
}
|
||||
wantImportParents := []string{"", "source-1", "source-1", "source-1", "source-1", "source-5", "source-1"}
|
||||
wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)}
|
||||
for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} {
|
||||
destination := append([]string(nil), test.prefix...)
|
||||
for _, segment := range suffix {
|
||||
destination = append(destination, segment)
|
||||
}
|
||||
wantDestinations = append(wantDestinations, configkit.Keys(destination...))
|
||||
}
|
||||
sources := test.result.Sources()
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v, want %d", sources, len(wantPaths))
|
||||
}
|
||||
for index, source := range sources {
|
||||
if filepath.Base(source.Path) != wantPaths[index] || source.Path != wantPhysicalPaths[index] || source.Destination.String() != wantDestinations[index].String() {
|
||||
t.Errorf("source %d path/destination = (%q, %q), want physical path %q and destination %q", index, source.Path, source.Destination.String(), wantPhysicalPaths[index], wantDestinations[index].String())
|
||||
}
|
||||
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFile {
|
||||
t.Errorf("source %d identity/kind = (%q, %q)", index, source.ID, source.Kind)
|
||||
}
|
||||
if wantImportParents[index] == "" {
|
||||
if source.ImportedFrom != nil {
|
||||
t.Errorf("entry source unexpectedly has an import location: %#v", source.ImportedFrom)
|
||||
}
|
||||
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != wantImportParents[index] || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||
t.Errorf("source %d import location = %#v, want declaration in %s", index, source.ImportedFrom, wantImportParents[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
||||
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
||||
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
||||
@@ -357,6 +490,135 @@ func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write := func(name, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
t.Run("imported parse failure retains effective source destination", func(t *testing.T) {
|
||||
mainPath := write("parse/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("parse/child.yml", "count: [unterminated\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("imported parse diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("imported parse failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directive failure stays source local", func(t *testing.T) {
|
||||
mainPath := write("directive/main.yml", "configkit:\n imports:\n - path: parent.yml\n destination: [tenant]\n")
|
||||
parentPath := write("directive/parent.yml", "configkit:\n imports:\n - path: details.yml\n destination: [3]\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid || !reflect.DeepEqual(diagnostics[0].Path, wantPath) || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("import directive diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != parentPath {
|
||||
t.Fatalf("directive failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("composition failure reports effective path and all sources", func(t *testing.T) {
|
||||
mainPath := write("compose/main.yml", "configkit:\n imports:\n - path: left.yml\n destination: [tenant]\n - path: right.yml\n destination: [tenant]\n")
|
||||
write("compose/left.yml", "value: same\n")
|
||||
write("compose/right.yml", "value: same\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeComposeConflict || diagnostics[0].Path.String() != "/mount/tenant/value" {
|
||||
t.Fatalf("composition diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[2].Destination.String() != "/mount/tenant" {
|
||||
t.Fatalf("composition failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version failure reports effective path and imported source", func(t *testing.T) {
|
||||
mainPath := write("version/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("version/child.yml", "version: forbidden\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("mount", "tenant", "version"),
|
||||
Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportVersion || diagnostics[0].Path.String() != "/mount/tenant/version" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("version diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("version failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed validation reports effective path and authored location", func(t *testing.T) {
|
||||
type configuration struct {
|
||||
Mount struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
} `yaml:"mount"`
|
||||
}
|
||||
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("typed/child.yml", "count: invalid\n")
|
||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeDecodeType || diagnostics[0].Path.String() != "/mount/tenant/count" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || diagnostics[0].Primary.Line != 1 {
|
||||
t.Fatalf("typed validation diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("typed validation sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
@@ -219,3 +221,77 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
|
||||
t.Fatal("CLI source returned a context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
|
||||
borrowed := &observedResourceFS{files: fstest.MapFS{
|
||||
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
|
||||
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
|
||||
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
|
||||
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
||||
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
|
||||
}}
|
||||
namespace := configkit.NewFilesystem("bundle", borrowed)
|
||||
input := configkit.Input{
|
||||
Source: namespace.File("bundle/config/main.yml"), Imports: true,
|
||||
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
||||
}
|
||||
load := func() *configkit.Result[map[string]any] {
|
||||
t.Helper()
|
||||
result, err := configkit.LoadLayered[map[string]any](input)
|
||||
if err != nil {
|
||||
t.Fatalf("load relative native imports: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
result := load()
|
||||
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
|
||||
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
|
||||
sources := result.Sources()
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
|
||||
t.Errorf("source %d = %#v", index, source)
|
||||
}
|
||||
if index == 0 {
|
||||
if source.ImportedFrom != nil {
|
||||
t.Errorf("entry source has import location %#v", source.ImportedFrom)
|
||||
}
|
||||
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
field, effectivePath, wantBase, wantResource, wantContents string
|
||||
}{
|
||||
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
|
||||
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
|
||||
} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
|
||||
if !ok || len(info.Origin.Locations) != 1 {
|
||||
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
|
||||
}
|
||||
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
|
||||
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
|
||||
if err != nil || string(contents) != test.wantContents {
|
||||
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
sources[1].Destination = configkit.Keys("caller", "mutation")
|
||||
for query := 0; query < 2; query++ {
|
||||
fresh := result.Sources()
|
||||
if fresh[1].Destination.String() != "/logical" {
|
||||
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
|
||||
}
|
||||
}
|
||||
second := load()
|
||||
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
|
||||
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
748
facade_source_version_mode_test.go
Normal file
748
facade_source_version_mode_test.go
Normal file
@@ -0,0 +1,748 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type sourceVersionModeReader struct {
|
||||
*strings.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
type header struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
type secretHeader struct {
|
||||
Version string `yaml:"version" configkit:"secret"`
|
||||
}
|
||||
type configuration struct {
|
||||
Meta map[string]header `yaml:"meta"`
|
||||
Data struct {
|
||||
Meta map[string]secretHeader `yaml:"meta"`
|
||||
} `yaml:"data"`
|
||||
}
|
||||
path, err := configkit.ParsePath("/meta/0/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reverse := range []bool{false, true} {
|
||||
groups := []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("mapping", []byte("meta: {\"0\": {version: PRIVATE-HEADER-CANARY}}\n")),
|
||||
Destination: configkit.Keys("data"),
|
||||
}}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("sequence", []byte("meta: [{version: another-header}]\n")),
|
||||
Destination: configkit.Keys("data"),
|
||||
}}},
|
||||
}
|
||||
if reverse {
|
||||
groups[0], groups[1] = groups[1], groups[0]
|
||||
}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("meta: {\"0\": {version: main}}\ndata: {meta: null}\n")),
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: configkit.Definition[configuration]{Version: &configkit.VersionRule{
|
||||
Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}},
|
||||
SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: groups,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Config.Meta["0"].Version != "main" || loaded.Config.Data.Meta != nil {
|
||||
t.Fatalf("reverse=%v: final payload changed: %#v", reverse, loaded.Config)
|
||||
}
|
||||
foundMapping := false
|
||||
for _, source := range loaded.Sources() {
|
||||
if source.Label != "mapping" {
|
||||
continue
|
||||
}
|
||||
foundMapping = true
|
||||
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
||||
}
|
||||
}
|
||||
if !foundMapping {
|
||||
t.Fatal("missing mapping source record")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModesPreserveImportOptIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||
for _, underlay := range []bool{false, true} {
|
||||
bad := configkit.Bytes("disabled-imports", []byte("version: accepted\nconfigkit: {imports: [{path: missing.yml}]}\nvalue: visible\n"))
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
var err error
|
||||
var resultPresent bool
|
||||
wantSource := "source-1"
|
||||
if underlay {
|
||||
loaded, loadErr := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: main\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: mode,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: bad}}}},
|
||||
})
|
||||
err, resultPresent, wantSource = loadErr, loaded != nil, "source-2"
|
||||
} else {
|
||||
loaded, loadErr := configkit.Load(bad, configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: mode})
|
||||
err, resultPresent = loadErr, loaded != nil
|
||||
}
|
||||
var failure *configkit.LoadError
|
||||
if resultPresent || !errors.As(err, &failure) {
|
||||
t.Errorf("mode=%v underlay=%v: import opt-in did not fail safely", mode, underlay)
|
||||
continue
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportDisabled || diagnostics[0].Path.String() != "/configkit" ||
|
||||
diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != wantSource {
|
||||
t.Errorf("mode=%v underlay=%v: diagnostics=%#v", mode, underlay, diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name, main, underlay string
|
||||
path configkit.Path
|
||||
}{
|
||||
{"missing member", "meta: {version: main}\n", "{}\n", configkit.Keys("meta", "version")},
|
||||
{"scalar obstruction", "meta: {version: main}\n", "meta: false\n", configkit.Keys("meta", "version")},
|
||||
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
||||
} {
|
||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||
var callbackEvidence *configkit.SourceVersionCheck
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: mode,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
source, found := resolution.Source("source-2")
|
||||
if !found {
|
||||
t.Fatal("missing callback source")
|
||||
}
|
||||
callbackEvidence = source.VersionCheck
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
var evidence *configkit.SourceVersionCheck
|
||||
if mode == configkit.SourceVersionModeRequired {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
t.Fatalf("%s: required omission did not fail", test.name)
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
||||
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
||||
}
|
||||
evidence = failure.Sources()[1].VersionCheck
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", test.name, err)
|
||||
}
|
||||
evidence = loaded.Sources()[1].VersionCheck
|
||||
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||
t.Errorf("%s: callback path is incomplete", test.name)
|
||||
}
|
||||
}
|
||||
if evidence == nil || evidence.Version.Present || evidence.Path.String() != test.path.String() ||
|
||||
evidence.Location == nil || evidence.Location.SourceID != "source-2" || evidence.Location.Line != 1 {
|
||||
t.Errorf("%s mode=%v: absence evidence=%#v", test.name, mode, evidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode configkit.SourceVersionMode
|
||||
control string
|
||||
wantCode configkit.Code
|
||||
}{
|
||||
{"optional omission", configkit.SourceVersionModeIfPresent, "base", ""},
|
||||
{"required omission", configkit.SourceVersionModeRequired, "base", configkit.CodeVersionError},
|
||||
{"invalid control", configkit.SourceVersionModeIfPresent, "true", configkit.CodeInheritanceInvalid},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
main := configkit.Bytes("main", []byte("{}\n"))
|
||||
if test.mode == configkit.SourceVersionModeRequired {
|
||||
main = configkit.NoSource()
|
||||
}
|
||||
checks := 0
|
||||
path := configkit.Keys("entries", "child", "extends", "version")
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main, Destination: configkit.Keys("main")}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: test.mode,
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Present {
|
||||
t.Error("checker received a nonexistent declaration")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte("entries:\n base: {value: good}\n child: {extends: "+test.control+"}\n")), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
if checks != 1 {
|
||||
t.Errorf("checker ran %d times, want absent main only", checks)
|
||||
}
|
||||
if test.wantCode != "" {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
t.Fatal("invalid input did not fail safely")
|
||||
}
|
||||
if diagnostics := failure.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode {
|
||||
t.Fatalf("diagnostics=%#v", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := loaded.Config["under"].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||
if child["value"] != "good" {
|
||||
t.Fatal("valid sibling inheritance was not applied")
|
||||
}
|
||||
check := loaded.Sources()[1].VersionCheck
|
||||
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
||||
t.Fatal("optional omission evidence was not retained")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
|
||||
reader.reads++
|
||||
return reader.Reader.Read(data)
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModesAcrossOrdinaryAndLayeredLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var checked []configkit.Version
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("meta", "version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
ordinary, err := configkit.Load(configkit.Bytes("ordinary", []byte("meta: {version: ordinary-v}\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || ordinary == nil {
|
||||
t.Fatalf("ordinary enabled load = %#v, %v", ordinary, err)
|
||||
}
|
||||
if got := ordinary.Config["meta"].(map[string]any)["version"]; got != "ordinary-v" {
|
||||
t.Fatalf("main declaration was not retained: %#v", ordinary.Config)
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("meta: {version: main-v}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layered, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Mapping("underlay", map[string]any{
|
||||
"meta": map[string]any{"version": "underlay-v"}, "fallback": "present",
|
||||
}),
|
||||
Destination: configkit.Keys("defaults"),
|
||||
}}}},
|
||||
})
|
||||
if err != nil || layered == nil {
|
||||
t.Fatalf("layered enabled load = %#v, %v", layered, err)
|
||||
}
|
||||
defaults, ok := layered.Config["defaults"].(map[string]any)
|
||||
if !ok || defaults["fallback"] != "present" {
|
||||
t.Fatalf("layered underlay payload = %#v", layered.Config["defaults"])
|
||||
}
|
||||
if meta, ok := defaults["meta"].(map[string]any); !ok || len(meta) != 0 {
|
||||
t.Fatalf("accepted underlay declaration was not consumed: %#v", defaults["meta"])
|
||||
}
|
||||
if len(checked) != 3 {
|
||||
t.Fatalf("checker calls across separate mode uses = %#v", checked)
|
||||
}
|
||||
for _, want := range []string{"ordinary-v", "main-v", "underlay-v"} {
|
||||
found := false
|
||||
for _, version := range checked {
|
||||
found = found || version.Present && version.Text == want
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("checked versions %#v do not include %q", checked, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModeZeroPreservesDefaultBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := 0
|
||||
result, err := configkit.Load(configkit.Bytes("default", []byte("version: v1\n")), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version != (configkit.Version{Present: true, Text: "v1"}) {
|
||||
t.Errorf("default version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil || result == nil || result.Config["version"] != "v1" || checks != 1 {
|
||||
t.Fatalf("zero-mode load = %#v, %v; checks = %d", result, err, checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModeValidationPrecedesSourceReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode configkit.SourceVersionMode
|
||||
def configkit.Definition[map[string]any]
|
||||
layered bool
|
||||
}{
|
||||
{name: "unknown ordinary mode", mode: configkit.SourceVersionMode(255), def: validDefinition},
|
||||
{name: "ordinary mode requires checker", mode: configkit.SourceVersionModeIfPresent},
|
||||
{name: "unknown layered mode", mode: configkit.SourceVersionMode(255), def: validDefinition, layered: true},
|
||||
{name: "layered mode requires checker", mode: configkit.SourceVersionModeRequired, layered: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := &sourceVersionModeReader{Reader: strings.NewReader("version: v1\n")}
|
||||
source := configkit.Reader("version.yml", reader)
|
||||
var err error
|
||||
if test.layered {
|
||||
_, err = configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: test.mode,
|
||||
})
|
||||
} else {
|
||||
_, err = configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: test.mode,
|
||||
})
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("invalid mode error = %#v", err)
|
||||
}
|
||||
if reader.reads != 0 {
|
||||
t.Fatalf("invalid options read source %d times", reader.reads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := 0
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Present {
|
||||
t.Errorf("missing declaration reached checker as %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
source := configkit.Bytes("reused", []byte("value: retained\n"))
|
||||
ifPresent, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || ifPresent == nil || checks != 1 {
|
||||
t.Fatalf("if-present load = %#v, %v; checks = %d", ifPresent, err, checks)
|
||||
}
|
||||
required, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if required != nil || !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeVersionError {
|
||||
t.Fatalf("required load = %#v, %v", required, err)
|
||||
}
|
||||
if checks != 1 {
|
||||
t.Fatalf("required absence reached checker: calls = %d", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const canary = "PRIVATE-SOURCE-VERSION-CANARY"
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(path, []byte("version: "+canary+"\nvalue: visible\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var callbackSource configkit.SourceInfo
|
||||
var callbackContext configkit.ResourceContext
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
var found bool
|
||||
callbackSource, found = resolution.Source("source-1")
|
||||
if !found {
|
||||
t.Fatal("resolution callback could not query the acquired source")
|
||||
}
|
||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck)
|
||||
}
|
||||
callbackContext, found = resolution.SourceContext("source-1")
|
||||
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
t.Fatalf("callback source context = %#v, found=%v", callbackContext, found)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Destination: configkit.Keys("service")}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||
}
|
||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck)
|
||||
}
|
||||
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
|
||||
callbackSource.VersionCheck.Version.Text = canary
|
||||
callbackSource.VersionCheck.Location.Line = 98
|
||||
finalSources := loaded.Sources()
|
||||
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
|
||||
t.Fatalf("final sources = %#v", finalSources)
|
||||
}
|
||||
check := finalSources[0].VersionCheck
|
||||
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
|
||||
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
|
||||
t.Fatalf("final classified evidence = %#v", check)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
t.Fatalf("resource metadata changed after removing the source: %#v", callbackContext)
|
||||
}
|
||||
check.Path = configkit.Keys("mutated")
|
||||
check.Location.Line = 99
|
||||
check.Redacted = false
|
||||
check.Version.Text = "mutated"
|
||||
repeated := loaded.Sources()
|
||||
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" ||
|
||||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted ||
|
||||
repeated[0].VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
||||
}
|
||||
|
||||
publicText := "PUBLIC-SOURCE-VERSION"
|
||||
var publicCallback configkit.SourceInfo
|
||||
public, err := configkit.Load(configkit.Bytes("public", []byte("version: "+publicText+"\n")), configkit.Options[map[string]any]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Version: definition.Version,
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
publicCallback, _ = resolution.Source("source-1")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
|
||||
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
|
||||
}
|
||||
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
|
||||
t.Fatalf("unclassified evidence = %#v", got)
|
||||
}
|
||||
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
t.Run("early import acquisition failure", func(t *testing.T) {
|
||||
const canary = "EARLY-ACQUISITION-SOURCE-VERSION-CANARY"
|
||||
checked := 0
|
||||
earlyDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||
}}
|
||||
path := filepath.Join(t.TempDir(), "main.yml")
|
||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Imports: true}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: earlyDefinition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||
t.Fatalf("early acquisition failure sources = %#v, error %v", failure, err)
|
||||
}
|
||||
if checked != 1 {
|
||||
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("early acquisition failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
})
|
||||
_, err := configkit.Load(configkit.Bytes("malformed", []byte("version: true\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("malformed version error = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted ||
|
||||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" {
|
||||
t.Fatalf("failed-load source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].VersionCheck.Path = configkit.Keys("mutated-error")
|
||||
sources[0].VersionCheck.Version.Text = "mutated"
|
||||
sources[0].VersionCheck.Location.Line = 99
|
||||
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
||||
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
def configkit.Definition[map[string]any]
|
||||
canary string
|
||||
}{
|
||||
{
|
||||
name: "resolver failure",
|
||||
canary: "RESOLVER-SOURCE-VERSION-CANARY",
|
||||
def: configkit.Definition[map[string]any]{Version: definition.Version, Resolve: func(*map[string]any, *configkit.Resolution) error {
|
||||
return errors.New("resolver failed")
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "validator failure",
|
||||
canary: "VALIDATOR-SOURCE-VERSION-CANARY",
|
||||
def: configkit.Definition[map[string]any]{Version: definition.Version, Validate: func(_ *map[string]any, validation *configkit.Validation) {
|
||||
validation.Error(configkit.Keys("value"), "invalid value")
|
||||
}},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := configkit.Load(configkit.Bytes("late-failure", []byte("version: "+test.canary+"\nvalue: configured\n")), configkit.Options[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||
t.Fatalf("late failure sources = %#v, error %v", failure, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), test.canary) {
|
||||
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
|
||||
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil {
|
||||
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
||||
}
|
||||
|
||||
withCLI, err := configkit.Load(configkit.Bytes("main", []byte("version: 8\nvalue: old\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Overrides: []configkit.Override{{Path: configkit.Keys("value"), Value: "new", Flag: "--value"}},
|
||||
})
|
||||
if err != nil || withCLI == nil {
|
||||
t.Fatalf("enabled load with CLI source = %#v, %v", withCLI, err)
|
||||
}
|
||||
sawCLI := false
|
||||
for _, source := range withCLI.Sources() {
|
||||
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
|
||||
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
||||
}
|
||||
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
||||
}
|
||||
if !sawCLI {
|
||||
t.Fatalf("CLI source record missing: %#v", withCLI.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var checked []configkit.Version
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: 17\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("absent", []byte("value: visible\n")), Destination: configkit.Keys("absent")},
|
||||
{Source: configkit.Bytes("null", []byte("version: null\nvalue: visible\n")), Destination: configkit.Keys("nullable")},
|
||||
}}},
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||
}
|
||||
if len(checked) != 2 || !checked[0].Present || checked[0].Text != "17" ||
|
||||
!checked[1].Present || !checked[1].Null {
|
||||
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil {
|
||||
t.Fatalf("source version evidence = %#v", sources)
|
||||
}
|
||||
absent := sources[1].VersionCheck
|
||||
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
||||
t.Fatalf("optional omission evidence = %#v", absent)
|
||||
}
|
||||
null := sources[2].VersionCheck
|
||||
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
||||
t.Fatalf("null declaration evidence = %#v", null)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *testing.T) {
|
||||
type serviceConfig struct {
|
||||
Version uint64 `yaml:"version"`
|
||||
Count int `yaml:"count"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
Revision string `yaml:"revision"`
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
Resolved string `yaml:"resolved"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
type configuration struct {
|
||||
Service serviceConfig `yaml:"service"`
|
||||
}
|
||||
t.Setenv("CONFIGKIT_LIFECYCLE_SECRET", "lifecycle-secret-value")
|
||||
var checked []configkit.Version
|
||||
var validationCalls int
|
||||
definition := configkit.Definition[configuration]{
|
||||
Defaults: func() configuration {
|
||||
return configuration{Service: serviceConfig{Defaulted: "from defaults"}}
|
||||
},
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Service.Resolved = "resolved once"
|
||||
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
|
||||
return nil
|
||||
},
|
||||
Validate: func(current *configuration, validation *configkit.Validation) {
|
||||
validationCalls++
|
||||
if current.Service.Version != 9007199254740993 || current.Service.Count != 0 || current.Service.Enabled ||
|
||||
current.Service.Nullable != nil || current.Service.Revision != "ordinary-payload" || current.Service.Defaulted != "from defaults" ||
|
||||
current.Service.Resolved != "resolved once" || current.Service.Credential.Value() != "lifecycle-secret-value" {
|
||||
validation.Error(configkit.Keys("service"), "assembled values did not survive source declaration processing")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
load := func(header string) *configkit.Result[configuration] {
|
||||
t.Helper()
|
||||
result, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("version: 9007199254740993\n")), Destination: configkit.Keys("service"),
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte("version: "+header+"\ncount: 0\nenabled: false\nnullable: null\nrevision: ordinary-payload\ncredential: CONFIGKIT_LIFECYCLE_SECRET\n")),
|
||||
Destination: configkit.Keys("service"),
|
||||
}}}},
|
||||
})
|
||||
if err != nil || result == nil {
|
||||
var failure *configkit.LoadError
|
||||
var diagnostics []configkit.Diagnostic
|
||||
if errors.As(err, &failure) {
|
||||
diagnostics = failure.Diagnostics()
|
||||
}
|
||||
t.Fatalf("LoadLayered(%q) = %#v, %v; diagnostics %#v", header, result, err, diagnostics)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
first := load("underlay-release-a")
|
||||
second := load("underlay-release-b")
|
||||
if len(checked) != 4 || checked[0].Text != "9007199254740993" || checked[1].Text != "underlay-release-a" ||
|
||||
checked[2].Text != "9007199254740993" || checked[3].Text != "underlay-release-b" {
|
||||
t.Fatalf("source declarations checked = %#v", checked)
|
||||
}
|
||||
if validationCalls != 2 || first.Config.Service != second.Config.Service {
|
||||
t.Fatalf("final configs or validation count = %#v, %#v, %d", first.Config, second.Config, validationCalls)
|
||||
}
|
||||
if differences, err := configkit.Diff(first, second); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("different consumed declarations created effective differences: %#v, %v", differences, err)
|
||||
}
|
||||
var firstEffective, secondEffective bytes.Buffer
|
||||
if err := first.WriteEffective(&firstEffective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.WriteEffective(&secondEffective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstEffective.String() != secondEffective.String() || strings.Contains(firstEffective.String(), "underlay-release") {
|
||||
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
|
||||
}
|
||||
sourcesA, sourcesB := first.Sources(), second.Sources()
|
||||
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil ||
|
||||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" {
|
||||
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
|
||||
}
|
||||
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
|
||||
if !found {
|
||||
t.Fatal("retained main version missing from effective explanation")
|
||||
}
|
||||
underlayID := sourcesA[1].ID
|
||||
for _, location := range versionExplanation.Origin.Locations {
|
||||
if location.SourceID == underlayID {
|
||||
t.Fatalf("consumed underlay declaration entered effective origin: %#v", versionExplanation.Origin)
|
||||
}
|
||||
}
|
||||
for _, change := range versionExplanation.History {
|
||||
for _, location := range change.Origin.Locations {
|
||||
if location.SourceID == underlayID {
|
||||
t.Fatalf("consumed underlay declaration entered effective history: %#v", versionExplanation.History)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ func normalizeDestination(path Path) (Path, error) {
|
||||
return Path{segments: segments}, nil
|
||||
}
|
||||
|
||||
// appendDestination combines validated mapping-key paths with owned segments.
|
||||
func appendDestination(parent, relative Path) Path {
|
||||
segments := clonePathSegments(parent.segments, len(relative.segments))
|
||||
segments = append(segments, relative.segments...)
|
||||
return Path{segments: segments}
|
||||
}
|
||||
|
||||
func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: CodeOptionsInvalid, Severity: SeverityError,
|
||||
@@ -37,12 +44,6 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
@@ -120,6 +122,207 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: empty.yml
|
||||
destination: [groups, empty]
|
||||
`)
|
||||
fragmentPath := writeImportFile(t, directory, "empty.yml", "{}\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
fragment, diagnostics := parseSource(File(fragmentPath), "source-2", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("empty fragment diagnostics = %#v", diagnostics)
|
||||
}
|
||||
entryDestination := Keys("collections", "entry")
|
||||
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
|
||||
wantNodes := countInputNodes(main.document) + countInputNodes(fragment.document) +
|
||||
destinationWrapperNodeCount(entryDestination) + destinationWrapperNodeCount(importDestination)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1, wantError: true},
|
||||
{name: "at aggregate bound"},
|
||||
{name: "above aggregate bound", delta: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
currentLimits := limits
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||
)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
}
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
if sources[1].Destination.String() != importDestination.String() {
|
||||
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty relative import body = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: fragments/*.yml
|
||||
destination: [groups, empty]
|
||||
`)
|
||||
fragmentPaths := []string{
|
||||
writeImportFile(t, directory, "fragments/a.yml", "{}\n"),
|
||||
writeImportFile(t, directory, "fragments/b.yml", "{}\n"),
|
||||
writeImportFile(t, directory, "fragments/c.yml", "{}\n"),
|
||||
}
|
||||
main := parseImportMain(t, mainPath)
|
||||
fragment, diagnostics := parseSource(File(fragmentPaths[0]), "source-fragment", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("glob fragment diagnostics = %#v", diagnostics)
|
||||
}
|
||||
entryDestination := Keys("collections", "entry")
|
||||
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
|
||||
wantNodes := countInputNodes(main.document) + len(fragmentPaths)*countInputNodes(fragment.document) +
|
||||
destinationWrapperNodeCount(entryDestination) + len(fragmentPaths)*destinationWrapperNodeCount(importDestination)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1, wantError: true},
|
||||
{name: "at aggregate bound"},
|
||||
{name: "above aggregate bound", delta: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
currentLimits := limits
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||
)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 {
|
||||
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
}
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
for index, source := range sources[1:] {
|
||||
if source.Destination.String() != importDestination.String() {
|
||||
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
|
||||
}
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationUsesAccumulatedDocumentDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml, destination: [extra]}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "value: nested\n")
|
||||
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
|
||||
for index := range destination.segments {
|
||||
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
|
||||
}
|
||||
_, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: destination}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("accumulated depth result = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || len(sources[0].Destination.segments) != maxDocumentDepth || len(sources[1].Destination.segments) != maxDocumentDepth+1 {
|
||||
t.Fatalf("depth failure source destinations = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
|
||||
for index := range destination.segments {
|
||||
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
setup func(*testing.T, string)
|
||||
}{
|
||||
{
|
||||
name: "optional missing path",
|
||||
body: "configkit: {imports: [{path: missing.yml, optional: true, destination: [unused]}]}\n",
|
||||
},
|
||||
{
|
||||
name: "optional unmatched glob",
|
||||
body: "configkit: {imports: [{glob: empty/*.yml, optional: true, destination: [unused]}]}\n",
|
||||
setup: func(t *testing.T, directory string) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if test.setup != nil {
|
||||
test.setup(t, directory)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", test.body)
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
||||
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
}
|
||||
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty entry body = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
211
internal/engine/destination_import_lifecycle_test.go
Normal file
211
internal/engine/destination_import_lifecycle_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type entry struct {
|
||||
Extends string `yaml:"extends"`
|
||||
Label string `yaml:"label"`
|
||||
Token *Secret `yaml:"token" configkit:"secret"`
|
||||
Dynamic string `yaml:"dynamic"`
|
||||
Derived string `yaml:"derived"`
|
||||
Height int `yaml:"height"`
|
||||
}
|
||||
type configuration struct {
|
||||
Catalog struct {
|
||||
Entries map[string]entry `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
|
||||
load := func(dynamic string) *testResult[configuration] {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "entry.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
destination: [entries]
|
||||
- path: child.yml
|
||||
destination: [entries]
|
||||
`)
|
||||
writeImportFile(t, directory, "parent.yml", `parent:
|
||||
label: lower
|
||||
token: PARENT_TOKEN
|
||||
height: 1
|
||||
`)
|
||||
writeImportFile(t, directory, "child.yml", fmt.Sprintf(`child:
|
||||
extends: parent
|
||||
label: child
|
||||
dynamic: %s
|
||||
height: 9
|
||||
`, dynamic))
|
||||
|
||||
var defaultsCalls, resolveCalls, validateCalls int
|
||||
secretPaths := make(map[string]bool)
|
||||
lookupCalls := make(map[string]int)
|
||||
request := loadRequest[configuration]{
|
||||
main: loadInput{
|
||||
source: Bytes("main", []byte("parent: {label: final, height: 4}\n")),
|
||||
destination: Keys("catalog", "entries"),
|
||||
},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: Keys("catalog"),
|
||||
}}}},
|
||||
definition: Definition[configuration]{
|
||||
Defaults: func() configuration {
|
||||
defaultsCalls++
|
||||
var defaults configuration
|
||||
defaults.Catalog.Entries = map[string]entry{"fallback": {Label: "default"}}
|
||||
return defaults
|
||||
},
|
||||
Resolve: func(config *configuration, resolution *Resolution) error {
|
||||
resolveCalls++
|
||||
child := config.Catalog.Entries["child"]
|
||||
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
|
||||
config.Catalog.Entries["child"] = child
|
||||
resolution.Record(Keys("catalog", "entries", "child", "derived"), ChangeNormalization, "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(config *configuration, validation *Validation) {
|
||||
validateCalls++
|
||||
parent := config.Catalog.Entries["parent"]
|
||||
child := config.Catalog.Entries["child"]
|
||||
if parent.Label != "final" || parent.Height != 4 || child.Label != "child" || child.Height != 9 || child.Derived != child.Label+":"+child.Dynamic+":9" || child.Token == nil || !child.Token.IsResolved() || child.Token.Value() != "parent-secret-canary" {
|
||||
validation.Error(Keys("catalog", "entries", "child"), "final imported configuration was not available to validation")
|
||||
}
|
||||
},
|
||||
Secret: func(path Path) bool {
|
||||
secretPaths[path.String()] = true
|
||||
return path.String() == "/catalog/entries/child/dynamic"
|
||||
},
|
||||
},
|
||||
}
|
||||
loaded, err := loadGroupedWithSecretLookup(request, testSecretBinding(), func(name string) (string, bool) {
|
||||
lookupCalls[name]++
|
||||
if name == "PARENT_TOKEN" {
|
||||
return "parent-secret-canary", true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("relative import lifecycle diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("relative import lifecycle: %v", err)
|
||||
}
|
||||
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || lookupCalls["PARENT_TOKEN"] != 1 {
|
||||
t.Fatalf("lifecycle calls defaults=%d resolve=%d validate=%d lookups=%#v", defaultsCalls, resolveCalls, validateCalls, lookupCalls)
|
||||
}
|
||||
if !secretPaths["/catalog/entries/child/dynamic"] {
|
||||
t.Fatalf("secret classifier did not see effective paths: %#v", secretPaths)
|
||||
}
|
||||
return testLoaded(loaded)
|
||||
}
|
||||
|
||||
left := load("LEFT-DYNAMIC-CANARY")
|
||||
child := left.Config.Catalog.Entries["child"]
|
||||
if child.Extends != "" || child.Label != "child" || child.Height != 9 || child.Derived != "child:LEFT-DYNAMIC-CANARY:9" || child.Token == nil || child.Token.Value() != "parent-secret-canary" {
|
||||
t.Fatalf("resolved imported child = %#v", child)
|
||||
}
|
||||
if left.Config.Catalog.Entries["parent"].Label != "final" || left.Config.Catalog.Entries["parent"].Height != 4 || left.Config.Catalog.Entries["fallback"].Label != "default" {
|
||||
t.Fatalf("final parent/default shape = %#v", left.Config.Catalog.Entries)
|
||||
}
|
||||
|
||||
childTokenPath := Keys("catalog", "entries", "child", "token")
|
||||
childToken, found := left.Inspection.Explain(childTokenPath)
|
||||
childTokenInfo, tokenInfoFound := left.Inspection.Metadata().Lookup(childTokenPath)
|
||||
if !found || !tokenInfoFound || !childToken.Effective.Redacted || strings.Contains(childToken.Effective.YAML, "parent-secret-canary") || len(childTokenInfo.Origin.Inheritance) != 1 {
|
||||
t.Fatalf("inherited field explanation = %#v, %v", childToken, found)
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := left.Inspection.WriteExplanation(&explanation, childTokenPath); err != nil || strings.Contains(explanation.String(), "parent-secret-canary") || strings.Contains(explanation.String(), "LEFT-DYNAMIC-CANARY") {
|
||||
t.Fatalf("safe explanation = %q, %v", explanation.String(), err)
|
||||
}
|
||||
labelHistory := left.Inspection.Metadata().History(Keys("catalog", "entries", "parent", "label"))
|
||||
if len(labelHistory) < 2 {
|
||||
t.Fatalf("parent label history omitted replaced contribution: %#v", labelHistory)
|
||||
}
|
||||
derived, found := left.Inspection.Explain(Keys("catalog", "entries", "child", "derived"))
|
||||
if !found || !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "LEFT-DYNAMIC-CANARY") || len(derived.History) == 0 || derived.History[len(derived.History)-1].Origin.Operation != ChangeNormalization {
|
||||
t.Fatalf("recorded derived field explanation = %#v, %v", derived, found)
|
||||
}
|
||||
|
||||
right := load("RIGHT-DYNAMIC-CANARY")
|
||||
differences, err := Diff(left.Inspection, right.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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, "DYNAMIC-CANARY") || strings.Contains(difference.Left.YAML+difference.Right.YAML, "parent-secret-canary") {
|
||||
t.Errorf("sensitive difference at %s = %#v", path, difference)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/catalog/entries/child/dynamic", "/catalog/entries/child/derived"} {
|
||||
if !seen[path] {
|
||||
t.Errorf("missing inherited/derived difference at %s: %#v", path, differences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeImportMatchesWrappedPlacementAndSafeDiff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
load := func(relativeImport bool, value string) *testResult[map[string]any] {
|
||||
t.Helper()
|
||||
input := Input{Source: NoSource()}
|
||||
options := LayeredOptions[map[string]any]{}
|
||||
if relativeImport {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: fragment.yml
|
||||
destination: [application]
|
||||
`)
|
||||
writeImportFile(t, directory, "fragment.yml", "field: "+value+"\n")
|
||||
options.Underlays = []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}}
|
||||
} else {
|
||||
options.Underlays = []Underlay{{Inputs: []Input{{Source: Bytes("wrapped", []byte("application: {field: "+value+"}\n"))}}}}
|
||||
}
|
||||
loaded, err := LoadLayered(input, testSecretBinding(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
return testLoaded(loaded)
|
||||
}
|
||||
|
||||
relativeBefore := load(true, "before")
|
||||
relativeAfter := load(true, "after")
|
||||
wrappedBefore := load(false, "before")
|
||||
wrappedAfter := load(false, "after")
|
||||
for _, pair := range [][2]*testResult[map[string]any]{{relativeBefore, wrappedBefore}, {relativeAfter, wrappedAfter}} {
|
||||
if !reflect.DeepEqual(pair[0].Config, pair[1].Config) {
|
||||
t.Fatalf("relative and wrapped configurations differ: %#v vs %#v", pair[0].Config, pair[1].Config)
|
||||
}
|
||||
for _, result := range pair {
|
||||
if info, ok := result.Metadata().Lookup(Keys("application", "field")); !ok || !info.Explicit {
|
||||
t.Fatalf("effective field presence = %#v, %v", info, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
relativeDiff, err := Diff(relativeBefore.Inspection, relativeAfter.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrappedDiff, err := Diff(wrappedBefore.Inspection, wrappedAfter.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(relativeDiff, wrappedDiff) {
|
||||
t.Fatalf("relative safe diff = %#v, wrapped safe diff = %#v", relativeDiff, wrappedDiff)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,343 @@ first: one
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTraversalUsesPerDeclarationDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
- path: sibling.yml
|
||||
- glob: fragments/*.yml
|
||||
`)
|
||||
parentPath := writeImportFile(t, directory, "parent.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/child.yml
|
||||
parent: true
|
||||
`)
|
||||
childPath := writeImportFile(t, directory, "nested/child.yml", "child: true\n")
|
||||
siblingPath := writeImportFile(t, directory, "sibling.yml", "sibling: true\n")
|
||||
alphaPath := writeImportFile(t, directory, "fragments/a.yml", "alpha: true\n")
|
||||
betaPath := writeImportFile(t, directory, "fragments/b.yml", "beta: true\n")
|
||||
|
||||
main := parseImportMain(t, mainPath)
|
||||
_, declarations, diagnostics := splitImportDirectives(main.document)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("main directive diagnostics = %#v", diagnostics)
|
||||
}
|
||||
declarations[0].destination = Keys("parent")
|
||||
declarations[1].destination = Keys("sibling")
|
||||
declarations[2].destination = Keys("collection")
|
||||
|
||||
access, err := openHostAccess(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = access.close() }()
|
||||
state := importTraversal{
|
||||
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
|
||||
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
|
||||
}
|
||||
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
|
||||
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
wantSources := []struct {
|
||||
path string
|
||||
destination string
|
||||
}{
|
||||
{path: parentPath, destination: "/entry/parent"},
|
||||
{path: childPath, destination: "/entry/parent"},
|
||||
{path: siblingPath, destination: "/entry/sibling"},
|
||||
{path: alphaPath, destination: "/entry/collection"},
|
||||
{path: betaPath, destination: "/entry/collection"},
|
||||
}
|
||||
if len(state.sources) != len(wantSources) {
|
||||
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
|
||||
}
|
||||
for index, want := range wantSources {
|
||||
got := state.sources[index]
|
||||
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
||||
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
||||
}
|
||||
}
|
||||
|
||||
composed, diagnostics := composeDocuments(state.documents...)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composition diagnostics = %#v", diagnostics)
|
||||
}
|
||||
for _, path := range []Path{
|
||||
Keys("entry", "parent", "parent"), Keys("entry", "parent", "child"),
|
||||
Keys("entry", "sibling", "sibling"), Keys("entry", "collection", "alpha"),
|
||||
Keys("entry", "collection", "beta"),
|
||||
} {
|
||||
if _, present := documentAtPath(composed.root, path); !present {
|
||||
t.Errorf("no composed value at %q", path.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("import context failure", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||
input, diagnostics := assembleBaseInputTracked(
|
||||
File(mainPath),
|
||||
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
|
||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("context diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(input.sources) != 1 || input.sources[0].Destination.String() != "/mounted" {
|
||||
t.Fatalf("entry source fallback = %#v", input.sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("imported parse failure", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||
writeImportFile(t, directory, "broken.yml", "broken: [\n")
|
||||
input, diagnostics := assembleBaseInputTracked(
|
||||
File(mainPath), Options[map[string]any]{Imports: true},
|
||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(input.sources) != 2 {
|
||||
t.Fatalf("parse-failure sources = %#v", input.sources)
|
||||
}
|
||||
for index, source := range input.sources {
|
||||
if source.Destination.String() != "/mounted" {
|
||||
t.Errorf("source %d destination = %q, want /mounted", index, source.Destination.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationsKeepStrictCompositionBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
main string
|
||||
files map[string]string
|
||||
want map[string]any
|
||||
conflict bool
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "disjoint peers at one destination",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||
files: map[string]string{"left.yml": "left: one\n", "right.yml": "right: two\n"},
|
||||
want: map[string]any{"/application/left": "one", "/application/right": "two"},
|
||||
},
|
||||
{
|
||||
name: "different destinations",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [left]\n - path: right.yml\n destination: [right]\n",
|
||||
files: map[string]string{"left.yml": "value: shared\n", "right.yml": "value: shared\n"},
|
||||
want: map[string]any{"/left/value": "shared", "/right/value": "shared"},
|
||||
},
|
||||
{
|
||||
name: "ancestor and descendant overlap",
|
||||
main: "configkit:\n imports:\n - path: parent.yml\n destination: [application]\n - path: child.yml\n destination: [application, branch]\n",
|
||||
files: map[string]string{"parent.yml": "branch: {base: one}\n", "child.yml": "leaf: two\n"},
|
||||
want: map[string]any{"/application/branch/base": "one", "/application/branch/leaf": "two"},
|
||||
},
|
||||
{
|
||||
name: "collision with explicitly wrapped content",
|
||||
main: "configkit: {imports: [{path: fragment.yml, destination: [application]}]}\napplication: {value: root}\n",
|
||||
files: map[string]string{"fragment.yml": "value: imported\n"},
|
||||
conflict: true,
|
||||
wantPath: "/application/value",
|
||||
},
|
||||
{
|
||||
name: "equal peer values conflict",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||
files: map[string]string{"left.yml": "value: same\n", "right.yml": "value: same\n"},
|
||||
conflict: true,
|
||||
wantPath: "/application/value",
|
||||
},
|
||||
{
|
||||
name: "unequal peer values conflict",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||
files: map[string]string{"left.yml": "value: left\n", "right.yml": "value: right\n"},
|
||||
conflict: true,
|
||||
wantPath: "/application/value",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", test.main)
|
||||
for name, body := range test.files {
|
||||
writeImportFile(t, directory, name, body)
|
||||
}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
if test.conflict {
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("relative import composition = %#v, %v", loaded, err)
|
||||
}
|
||||
loadError := err.(*LoadError)
|
||||
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.wantPath {
|
||||
t.Fatalf("conflict path = %q, want %q", diagnostics[0].Path.String(), test.wantPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("relative import composition: %v", err)
|
||||
}
|
||||
configuration := testLoaded(loaded).Config
|
||||
for path, want := range test.want {
|
||||
parsed, err := ParsePath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := make([]string, len(parsed.segments))
|
||||
for index, segment := range parsed.segments {
|
||||
keys[index] = segment.key
|
||||
}
|
||||
if got := layeredMapValue(t, configuration, keys...); got != want {
|
||||
t.Errorf("value at %s = %#v, want %#v", path, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("upper tier cannot repair a lower import conflict", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
lowerPath := writeImportFile(t, directory, "lower.yml", `configkit:
|
||||
imports:
|
||||
- path: left.yml
|
||||
destination: [application]
|
||||
- path: right.yml
|
||||
destination: [application]
|
||||
`)
|
||||
writeImportFile(t, directory, "left.yml", "value: left\n")
|
||||
writeImportFile(t, directory, "right.yml", "value: right\n")
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("upper", []byte("application: {value: upper}\n"))},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{Source: File(lowerPath), Imports: true}}}}},
|
||||
)
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("upper tier repaired a lower conflict: %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationsUseEffectiveVersionOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rulePath Path
|
||||
fragment string
|
||||
}{
|
||||
{name: "direct claim", rulePath: Keys("collection", "group", "nested", "version"), fragment: "version: v2\n"},
|
||||
{name: "ancestor claim", rulePath: Keys("collection", "group", "nested", "schema", "version"), fragment: "schema: null\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
destination: [group]
|
||||
`)
|
||||
writeImportFile(t, directory, "parent.yml", `configkit:
|
||||
imports:
|
||||
- path: fragment.yml
|
||||
destination: [nested]
|
||||
`)
|
||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||
_, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
|
||||
Path: test.rulePath, Check: func(Version, *Validation) error { return nil },
|
||||
}}},
|
||||
)
|
||||
if groupedErrorCode(t, err) != CodeImportVersion {
|
||||
t.Fatalf("effective imported claim error = %v", err)
|
||||
}
|
||||
loadError := err.(*LoadError)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Path.String() != test.rulePath.String() || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-3" {
|
||||
t.Fatalf("effective version diagnostic = %#v", diagnostic)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
wantDestinations := []string{"/collection", "/collection/group", "/collection/group/nested"}
|
||||
if len(sources) != len(wantDestinations) {
|
||||
t.Fatalf("version failure sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.Destination.String() != wantDestinations[index] {
|
||||
t.Errorf("source %d destination = %q, want %q", index, source.Destination.String(), wantDestinations[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationsKeepMainVersionCheckScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Collection struct {
|
||||
SchemaVersion string `yaml:"schema_version"`
|
||||
Group map[string]any `yaml:"group"`
|
||||
} `yaml:"collection"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
destination: [group]
|
||||
schema_version: 6
|
||||
`)
|
||||
writeImportFile(t, directory, "parent.yml", `configkit:
|
||||
imports:
|
||||
- path: fragment.yml
|
||||
destination: [nested]
|
||||
`)
|
||||
writeImportFile(t, directory, "fragment.yml", "version: 7\n")
|
||||
checks := 0
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{Definition: Definition[config]{Version: &VersionRule{
|
||||
Path: Keys("collection", "schema_version"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
checks++
|
||||
if !version.Present || version.Text != "6" {
|
||||
t.Errorf("main-owned version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary nested imported version was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if checks != 1 || result.Config.Collection.SchemaVersion != "6" {
|
||||
t.Fatalf("main version callback/value = %d/%q", checks, result.Config.Collection.SchemaVersion)
|
||||
}
|
||||
nested, ok := result.Config.Collection.Group["nested"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested imported payload = %#v", result.Config.Collection.Group["nested"])
|
||||
}
|
||||
if got, ok := nested["version"].(int64); !ok || got != 7 {
|
||||
t.Fatalf("ordinary nested version = %#v, want integer payload 7", nested["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -189,7 +526,7 @@ func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
||||
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "shared.yml", "value: shared\n")
|
||||
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml}]}\n")
|
||||
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml, destination: [nested]}]}\n")
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
@@ -205,4 +542,27 @@ func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 || sources[1].Destination.String() != "/imported/nested" || sources[2].Destination.String() != "/direct" {
|
||||
t.Fatalf("repeated import sources = %#v", sources)
|
||||
}
|
||||
|
||||
t.Run("placement does not break a physical cycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: parent.yml, destination: [one]}]}\n")
|
||||
writeImportFile(t, directory, "parent.yml", "configkit: {imports: [{path: main.yml, destination: [two]}]}\n")
|
||||
_, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, testSecretBinding(), LayeredOptions[map[string]any]{})
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportCycle {
|
||||
t.Fatalf("cycle load error = %#v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
|
||||
t.Fatalf("cycle primary location = %#v", diagnostic.Primary)
|
||||
}
|
||||
if sources := loadError.Sources(); len(sources) != 2 || sources[1].Destination.String() != "/one" {
|
||||
t.Fatalf("cycle sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,34 @@ func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDestinationCopiesLiteralSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := Keys("collection", "entry")
|
||||
relative := Keys("a/b", "0", "~")
|
||||
combined := appendDestination(parent, relative)
|
||||
if got, want := combined.String(), "/collection/entry/a~1b/0/~0"; got != want {
|
||||
t.Fatalf("combined destination = %q, want %q", got, want)
|
||||
}
|
||||
if len(combined.segments) != 5 {
|
||||
t.Fatalf("combined segments = %#v", combined.segments)
|
||||
}
|
||||
for index, segment := range combined.segments {
|
||||
if segment.kind != pathSegmentKey {
|
||||
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
|
||||
}
|
||||
}
|
||||
|
||||
combined.segments[0].key = "changed parent"
|
||||
combined.segments[2].key = "changed relative"
|
||||
if got := parent.String(); got != "/collection/entry" {
|
||||
t.Fatalf("combined path shares parent segment storage: %q", got)
|
||||
}
|
||||
if got := relative.String(); got != "/a~1b/0/~0" {
|
||||
t.Fatalf("combined path shares relative segment storage: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -63,8 +63,9 @@ func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||
}
|
||||
|
||||
type diagnosticAccumulator struct {
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
versionState *sourceVersionState
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||
@@ -74,9 +75,13 @@ func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||
sources := a.sources
|
||||
if a.versionState != nil {
|
||||
sources = sourceInfosWithVersionEvidence(sources, a.versionState.evidence, nil, true)
|
||||
}
|
||||
return &LoadError{
|
||||
diagnostics: canonicalDiagnostics(a.diagnostics),
|
||||
sources: cloneSourceInfos(a.sources),
|
||||
sources: cloneSourceInfos(sources),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
const directiveKey = "configkit"
|
||||
|
||||
type importDeclaration struct {
|
||||
path string
|
||||
glob string
|
||||
optional bool
|
||||
location Location
|
||||
path string
|
||||
glob string
|
||||
destination Path
|
||||
optional bool
|
||||
location Location
|
||||
}
|
||||
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
||||
@@ -90,6 +91,27 @@ func parseImportDeclaration(node *document.Node, declarationPath Path) (importDe
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
||||
}
|
||||
declaration.optional = entry.Value.Text == "true"
|
||||
case "destination":
|
||||
if entry.Value.Kind != document.Sequence {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must be a sequence of strings", entry.Value)}
|
||||
}
|
||||
relative := Path{segments: make([]pathSegment, 0, len(entry.Value.Sequence))}
|
||||
for index, item := range entry.Value.Sequence {
|
||||
memberPath := fieldPath.Index(index)
|
||||
if item.Kind != document.String {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be strings", item)}
|
||||
}
|
||||
member := Path{segments: []pathSegment{{kind: pathSegmentKey, key: item.Text}}}
|
||||
if _, err := normalizeDestination(member); err != nil {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, memberPath, "destination members must be non-reserved mapping keys", item)}
|
||||
}
|
||||
relative.segments = append(relative.segments, pathSegment{kind: pathSegmentKey, key: item.Text})
|
||||
}
|
||||
normalized, err := normalizeDestination(relative)
|
||||
if err != nil {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "destination must contain only non-reserved mapping keys", entry.Value)}
|
||||
}
|
||||
declaration.destination = normalized
|
||||
default:
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ type importAccounting struct {
|
||||
}
|
||||
|
||||
type importTraversal struct {
|
||||
access *filesystemAccess
|
||||
accounting *importAccounting
|
||||
limits importLimits
|
||||
rule *VersionRule
|
||||
destination Path
|
||||
access *filesystemAccess
|
||||
accounting *importAccounting
|
||||
limits importLimits
|
||||
rule *VersionRule
|
||||
versionState *sourceVersionState
|
||||
|
||||
documents []*document.Node
|
||||
sources []SourceInfo
|
||||
@@ -63,12 +63,24 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
||||
}
|
||||
|
||||
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) {
|
||||
return loadImportedDocumentsTrackedWithVersion(source, context, main, destination, rule, limits, accounting, visited, hostToken, nil)
|
||||
}
|
||||
|
||||
func loadImportedDocumentsTrackedWithVersion(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
var processing *sourceVersionProcessingResult
|
||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||
processing = versionState.mainProcessing
|
||||
}
|
||||
return loadImportedDocumentsTrackedWithProcessing(source, context, main, destination, rule, limits, accounting, visited, hostToken, versionState, processing)
|
||||
}
|
||||
|
||||
func loadImportedDocumentsTrackedWithProcessing(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, processing *sourceVersionProcessingResult) (*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, destination, rule, limits, accounting, visited)
|
||||
return loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
|
||||
}
|
||||
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
@@ -83,7 +95,7 @@ func loadImportedDocumentsTracked(source Source, context *FilesystemContext, mai
|
||||
if hostToken != nil {
|
||||
access.token = hostToken
|
||||
}
|
||||
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
|
||||
return loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
|
||||
}
|
||||
|
||||
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
@@ -91,6 +103,18 @@ func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string,
|
||||
}
|
||||
|
||||
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
return loadImportedWithAccessTrackedWithVersion(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, nil)
|
||||
}
|
||||
|
||||
func loadImportedWithAccessTrackedWithVersion(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
var processing *sourceVersionProcessingResult
|
||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||
processing = versionState.mainProcessing
|
||||
}
|
||||
return loadImportedWithAccessTrackedAndProcessing(access, basePath, mainPath, main, destination, rule, limits, accounting, visited, versionState, processing)
|
||||
}
|
||||
|
||||
func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, versionState *sourceVersionState, processing *sourceVersionProcessingResult) (*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)}
|
||||
@@ -119,11 +143,10 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
||||
accounting.bytes += main.bytes
|
||||
accounting.nodes += countInputNodes(main.document)
|
||||
state := importTraversal{
|
||||
access: access, accounting: accounting, limits: limits, rule: rule,
|
||||
destination: destination.clone(),
|
||||
documents: make([]*document.Node, 0, 1),
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int), visited: visited,
|
||||
access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState,
|
||||
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)
|
||||
@@ -141,10 +164,18 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
||||
state.active[mainKey] = 0
|
||||
state.visited[mainKey] = mainRoute
|
||||
state.stack = append(state.stack, mainRoute)
|
||||
body, declarations, diagnostics := splitImportDirectives(main.document)
|
||||
var body *document.Node
|
||||
var declarations []importDeclaration
|
||||
var diagnostics []Diagnostic
|
||||
if processing != nil {
|
||||
body = processing.payload
|
||||
declarations = processing.imports
|
||||
} else {
|
||||
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
placementNodes := destinationWrapperNodeCount(state.destination)
|
||||
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
placementNodes := destinationWrapperNodeCount(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 {
|
||||
@@ -152,7 +183,7 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
|
||||
} else {
|
||||
accounting.nodes += placementNodes
|
||||
state.documents = append(state.documents, placed)
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0)
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +218,9 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int, parentDestination Path) []Diagnostic {
|
||||
for _, declaration := range declarations {
|
||||
childDestination := appendDestination(parentDestination, declaration.destination)
|
||||
if declaration.glob != "" {
|
||||
matches, diagnostics := s.expandImportGlob(basePath, declaration)
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -205,20 +237,20 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
|
||||
matched := declaration
|
||||
matched.path = match
|
||||
matched.glob = ""
|
||||
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
|
||||
if diagnostics := s.visitPath(basePath, matched, childDestination, depth+1); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
|
||||
if diagnostics := s.visitPath(basePath, declaration, childDestination, depth+1); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
|
||||
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination Path, depth int) []Diagnostic {
|
||||
location := declaration.location
|
||||
if depth > s.limits.depth {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||
@@ -272,7 +304,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
kind = SourceKindFilesystem
|
||||
label = s.access.label + "/" + canonical
|
||||
}
|
||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.destination.clone(), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: destination.clone(), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||
if kind == SourceKindFilesystem {
|
||||
info.namespace = s.access.token
|
||||
}
|
||||
@@ -299,23 +331,38 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
}
|
||||
s.accounting.nodes += nodeCount
|
||||
|
||||
body, declarations, diagnostics := splitImportDirectives(root)
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
var body *document.Node
|
||||
var declarations []importDeclaration
|
||||
var diagnostics []Diagnostic
|
||||
if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault {
|
||||
processed, failures := s.versionState.process(root, sourceVersionContribution, destination, sourceID)
|
||||
if len(failures) != 0 {
|
||||
return failures
|
||||
}
|
||||
body, declarations = processed.payload, processed.imports
|
||||
} else {
|
||||
body, declarations, diagnostics = splitImportDirectives(root)
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
_, placed, err := placeDocument(body, s.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
_, placed, err := placeDocument(body, 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)
|
||||
placementNodes := destinationWrapperNodeCount(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 {
|
||||
protectionRule := s.rule
|
||||
if s.versionState != nil && s.versionState.effectiveRule != nil {
|
||||
protectionRule = s.versionState.effectiveRule
|
||||
}
|
||||
if versionNode := fragmentVersionClaim(placed, protectionRule); versionNode != nil {
|
||||
versionLocation := publicLocation(versionNode.Location)
|
||||
return []Diagnostic{{
|
||||
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
|
||||
Code: CodeImportVersion, Severity: SeverityError, Path: protectionRule.Path.clone(),
|
||||
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
|
||||
}}
|
||||
}
|
||||
@@ -325,7 +372,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
s.active[identity] = len(s.stack)
|
||||
s.visited[identity] = route
|
||||
s.stack = append(s.stack, route)
|
||||
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth)
|
||||
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth, destination)
|
||||
delete(s.active, identity)
|
||||
s.stack = s.stack[:len(s.stack)-1]
|
||||
return diagnostics
|
||||
|
||||
@@ -166,6 +166,105 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportDestinationUsesLiteralStringSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
destination string
|
||||
want Path
|
||||
}{
|
||||
{name: "omitted"},
|
||||
{name: "empty sequence", destination: " destination: []\n"},
|
||||
{
|
||||
name: "literal keys",
|
||||
destination: " destination: ['', '.', '..', 'a/b', '~', '0', configkit2, myextends]\n",
|
||||
want: Keys("", ".", "..", "a/b", "~", "0", "configkit2", "myextends"),
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := "configkit:\n imports:\n - path: child.yml\n" + test.destination
|
||||
declarations := parseImportDeclarationsYAML(t, body)
|
||||
if len(declarations) != 1 {
|
||||
t.Fatalf("declarations = %#v", declarations)
|
||||
}
|
||||
got := declarations[0].destination
|
||||
if got.String() != test.want.String() {
|
||||
t.Fatalf("destination = %q, want %q", got.String(), test.want.String())
|
||||
}
|
||||
for index, segment := range got.segments {
|
||||
if segment.kind != pathSegmentKey {
|
||||
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportDestinationRejectsInvalidShapesAndMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
wantPath string
|
||||
badMember string
|
||||
}{
|
||||
{name: "null", value: "null", wantPath: "/configkit/imports/0/destination", badMember: "null"},
|
||||
{name: "scalar string", value: "prompts", wantPath: "/configkit/imports/0/destination", badMember: "prompts"},
|
||||
{name: "number", value: "3", wantPath: "/configkit/imports/0/destination", badMember: "3"},
|
||||
{name: "non-string number", value: "[valid, 7]", wantPath: "/configkit/imports/0/destination/1", badMember: "7"},
|
||||
{name: "non-string null", value: "[valid, null]", wantPath: "/configkit/imports/0/destination/1", badMember: "null"},
|
||||
{name: "non-string boolean", value: "[valid, true]", wantPath: "/configkit/imports/0/destination/1", badMember: "true"},
|
||||
{name: "non-string mapping", value: "[valid, {key: value}]", wantPath: "/configkit/imports/0/destination/1", badMember: "{key"},
|
||||
{name: "reserved configkit", value: "[valid, configkit]", wantPath: "/configkit/imports/0/destination/1", badMember: "configkit"},
|
||||
{name: "reserved extends", value: "[valid, extends]", wantPath: "/configkit/imports/0/destination/1", badMember: "extends"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := "configkit:\n imports:\n - path: child.yml\n destination: " + test.value + "\n"
|
||||
parsed, diagnostics := parseImportDeclarationYAML(body)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != test.wantPath {
|
||||
t.Fatalf("parse = %#v, want import.invalid at %q", diagnostics, test.wantPath)
|
||||
}
|
||||
line := strings.Split(body, "\n")[3]
|
||||
wantColumn := strings.Index(line, test.badMember) + 1
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" || diagnostics[0].Primary.Line != 4 || diagnostics[0].Primary.Column != wantColumn {
|
||||
t.Fatalf("invalid destination location = %#v, want line 4 column %d (parsed %v)", diagnostics[0].Primary, wantColumn, parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDestinationValidationPrecedesOptionalSuppression(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "optional missing path",
|
||||
body: "configkit:\n imports:\n - path: absent.yml\n optional: true\n destination: null\n",
|
||||
path: "/configkit/imports/0/destination",
|
||||
},
|
||||
{
|
||||
name: "optional unmatched glob",
|
||||
body: "configkit:\n imports:\n - glob: absent/*.yml\n optional: true\n destination: [extends]\n",
|
||||
path: "/configkit/imports/0/destination/0",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", test.body)
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.path {
|
||||
t.Fatalf("diagnostic path = %q, want %q", diagnostics[0].Path.String(), test.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||
t.Run("optional missing", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
@@ -486,3 +585,21 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseImportDeclarationsYAML(t *testing.T, input string) []importDeclaration {
|
||||
t.Helper()
|
||||
declarations, diagnostics := parseImportDeclarationYAML(input)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("import parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return declarations
|
||||
}
|
||||
|
||||
func parseImportDeclarationYAML(input string) ([]importDeclaration, []Diagnostic) {
|
||||
parsed, diagnostics := parseSource(Bytes("import fixture", []byte(input)), "source-1", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
_, declarations, diagnostics := splitImportDirectives(parsed.document)
|
||||
return declarations, diagnostics
|
||||
}
|
||||
|
||||
@@ -92,6 +92,40 @@ func TestInheritanceSensitivityIncludesSchemaTaggedSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceVersionEvidenceSensitivityFollowsInheritanceAndDisplacedHistory(t *testing.T) {
|
||||
const canary = "EVIDENCE-INHERITANCE-CANARY"
|
||||
input := parseCompositionDocument(t, "source-1", "parent: {branch: {token: "+canary+", plain: visible}}\nchild: {extends: parent, branch: public}\n")
|
||||
inspection, _ := inheritanceInspectionForTest(t, input)
|
||||
secretPath := Keys("parent", "branch", "token")
|
||||
evidence := &sourceVersionEvidence{
|
||||
path: secretPath.clone(), resolvedPath: secretPath.clone(),
|
||||
version: Version{Present: true, Text: canary},
|
||||
ancestors: []sourceVersionAncestor{{}, {path: Keys("parent")}, {path: Keys("parent", "branch")}, {path: secretPath.clone()}},
|
||||
}
|
||||
inspection.secrets = classifySecretPathsWithEvidence(nil, func(path Path) bool {
|
||||
return path.String() == secretPath.String()
|
||||
}, inspection.metadata, inspection.final, inspection.applications, []*sourceVersionEvidence{evidence}, inspection.input)
|
||||
|
||||
path := Keys("child", "branch", "token")
|
||||
explanation, found := inspection.Explain(path)
|
||||
if !found || explanation.Effective.Present {
|
||||
t.Fatalf("displaced inherited path = %#v, %v", explanation, found)
|
||||
}
|
||||
for _, change := range explanation.History {
|
||||
if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) ||
|
||||
(change.After.Present && !change.After.Redacted) {
|
||||
t.Fatalf("displaced inherited canary escaped redaction: %#v", explanation.History)
|
||||
}
|
||||
}
|
||||
plain, found := inspection.Explain(Keys("parent", "branch", "plain"))
|
||||
if !found || plain.Effective.Redacted || plain.Effective.YAML != "visible\n" {
|
||||
t.Fatalf("neighboring value was classified with evidence: %#v, %v", plain, found)
|
||||
}
|
||||
if _, found := inspection.Metadata().Lookup(Keys("evidence-only")); found {
|
||||
t.Fatal("evidence classification created a metadata path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) {
|
||||
type entry struct {
|
||||
Credential Secret `yaml:"credential"`
|
||||
|
||||
@@ -16,6 +16,7 @@ type Underlay struct{ Inputs []Input }
|
||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||
type LayeredOptions[T any] struct {
|
||||
Definition Definition[T]
|
||||
SourceVersionMode sourceVersionMode
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
OverlayFilesystem *FilesystemContext
|
||||
@@ -37,7 +38,8 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
||||
destination: main.Destination.clone(),
|
||||
},
|
||||
definition: options.Definition, overlay: options.Overlay,
|
||||
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||
overlay: options.Overlay,
|
||||
overlayFilesystem: options.OverlayFilesystem,
|
||||
overrides: append([]Override(nil), options.Overrides...),
|
||||
underlays: make([]loadGroup, len(options.Underlays)),
|
||||
|
||||
@@ -22,11 +22,12 @@ type Definition[T any] struct {
|
||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||
// Overrides contains only values explicitly supplied by an application CLI.
|
||||
type Options[T any] struct {
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
SourceVersionMode sourceVersionMode
|
||||
}
|
||||
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||
@@ -143,6 +144,8 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
|
||||
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem},
|
||||
definition: options.Definition, overlay: options.Overlay,
|
||||
overrides: options.Overrides, ordinary: true,
|
||||
sourceVersionMode: options.SourceVersionMode,
|
||||
effectiveVersion: effectiveSourceVersionRule(options.Definition.Version, Path{}, options.SourceVersionMode),
|
||||
}
|
||||
return executeLoadRequest(request, secrets, lookup)
|
||||
}
|
||||
@@ -151,31 +154,55 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
accumulator := diagnosticAccumulator{}
|
||||
accounting := &importAccounting{}
|
||||
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem}
|
||||
mainOptions.SourceVersionMode = request.sourceVersionMode
|
||||
mainOptions.Definition.Version = request.protectionVersion()
|
||||
mainOptions.Overrides = request.overrides
|
||||
if request.ordinary {
|
||||
mainOptions.Overlay = request.overlay
|
||||
}
|
||||
|
||||
input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, request.main.destination, accounting, nil, nil)
|
||||
var schema *schemaDescriptor
|
||||
if request.sourceVersionMode != sourceVersionModeDefault {
|
||||
var schemaIssue *schemaIssue
|
||||
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
|
||||
if schemaIssue != nil {
|
||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
versionState := &sourceVersionState{
|
||||
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
||||
}
|
||||
accumulator.versionState = versionState
|
||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(versionState.diagnostics...)
|
||||
reportedVersionDiagnostics := len(versionState.diagnostics)
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
schema, schemaIssue := buildSchema(reflect.TypeFor[T](), secrets)
|
||||
if schemaIssue != nil {
|
||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||
return nil, accumulator.loadError()
|
||||
if schema == nil {
|
||||
var schemaIssue *schemaIssue
|
||||
schema, schemaIssue = buildSchema(reflect.TypeFor[T](), secrets)
|
||||
if schemaIssue != nil {
|
||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
versionDiagnostics := checkVersion(request.definition.Version, input.document)
|
||||
accumulator.add(versionDiagnostics...)
|
||||
if hasErrors(versionDiagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
if request.sourceVersionMode == sourceVersionModeDefault {
|
||||
versionDiagnostics := checkVersion(request.definition.Version, input.document)
|
||||
accumulator.add(versionDiagnostics...)
|
||||
if hasErrors(versionDiagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
if request.ordinary {
|
||||
input, diagnostics = applyInputLayers(request.main.source, Options[T]{Definition: request.definition, Overlay: request.overlay, Filesystem: request.main.filesystem, Overrides: request.overrides}, input)
|
||||
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
|
||||
} else {
|
||||
input, diagnostics = assembleLayeredInput(request, input, accounting)
|
||||
}
|
||||
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -208,7 +235,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
accumulator.add(problems...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil {
|
||||
if problem := inheritanceVersionDiagnostic(request.protectionVersion(), expanded); problem != nil {
|
||||
accumulator.add(*problem)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
@@ -217,7 +244,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
input.document = expanded.root
|
||||
}
|
||||
|
||||
decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
|
||||
decodeDefinition := request.definition
|
||||
decodeDefinition.Version = request.protectionVersion()
|
||||
decoded, diagnostics := decodeLoadInput(schema, decodeDefinition, input)
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, accumulator.loadError()
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
|
||||
// assembledInput is the source/document handoff across schema and version checks.
|
||||
type assembledInput struct {
|
||||
parsed parsedSource
|
||||
document *document.Node
|
||||
composition *composedDocument
|
||||
inheritance *inheritanceScope
|
||||
sources []SourceInfo
|
||||
contexts map[string]ResourceContext
|
||||
parsed parsedSource
|
||||
document *document.Node
|
||||
composition *composedDocument
|
||||
inheritance *inheritanceScope
|
||||
sources []SourceInfo
|
||||
contexts map[string]ResourceContext
|
||||
versionState *sourceVersionState
|
||||
}
|
||||
|
||||
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
||||
@@ -21,14 +22,24 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
|
||||
}
|
||||
|
||||
func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
||||
return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil)
|
||||
}
|
||||
|
||||
func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (assembledInput, []Diagnostic) {
|
||||
return assembleBaseInputTrackedForRole(source, options, destination, accounting, visited, hostToken, versionState, sourceVersionMain)
|
||||
}
|
||||
|
||||
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
||||
limits := defaultImportLimits()
|
||||
parseLimits := defaultParseLimits()
|
||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
||||
input := assembledInput{parsed: parsed, document: parsed.document}
|
||||
if parsed.source != nil {
|
||||
parsed.source.Destination = destination.clone()
|
||||
}
|
||||
input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState}
|
||||
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 {
|
||||
@@ -40,16 +51,37 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
|
||||
}
|
||||
return input, diagnostics
|
||||
}
|
||||
if !options.Imports && parsed.document != nil {
|
||||
if diagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}); len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
}
|
||||
var versionProcessing *sourceVersionProcessingResult
|
||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||
var sourceID string
|
||||
if parsed.source != nil {
|
||||
sourceID = parsed.source.ID
|
||||
}
|
||||
processed, failures := versionState.process(parsed.document, role, destination, sourceID)
|
||||
if len(failures) != 0 {
|
||||
return input, failures
|
||||
}
|
||||
versionProcessing = &processed
|
||||
if role == sourceVersionMain {
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
}
|
||||
if options.Imports {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken)
|
||||
setSourceDestinations(input.sources, destination)
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
input.document = input.composition.root
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
setSourceDestinations(input.sources, destination)
|
||||
if versionProcessing != nil {
|
||||
input.document = versionProcessing.payload
|
||||
}
|
||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
@@ -57,9 +89,6 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
|
||||
}
|
||||
}
|
||||
if !options.Imports && input.document != nil {
|
||||
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
_, 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")}
|
||||
@@ -115,9 +144,9 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
|
||||
}
|
||||
assembly := newAssemblyAccumulator(composition)
|
||||
if activeOverlay(options.Overlay) {
|
||||
input.sources, diagnostics = loadSelectedOverlayInto(
|
||||
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
|
||||
source, options.Filesystem, input.parsed, assembly, input.sources,
|
||||
options.Overlay, options.Definition.Version, defaultImportLimits(),
|
||||
options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
|
||||
@@ -10,12 +10,21 @@ type loadRequest[T any] struct {
|
||||
main loadInput
|
||||
underlays []loadGroup
|
||||
definition Definition[T]
|
||||
sourceVersionMode sourceVersionMode
|
||||
effectiveVersion *VersionRule
|
||||
overlay string
|
||||
overlayFilesystem *FilesystemContext
|
||||
overrides []Override
|
||||
ordinary bool
|
||||
}
|
||||
|
||||
func (request loadRequest[T]) protectionVersion() *VersionRule {
|
||||
if request.effectiveVersion != nil {
|
||||
return request.effectiveVersion
|
||||
}
|
||||
return request.definition.Version
|
||||
}
|
||||
|
||||
type loadInput struct {
|
||||
source Source
|
||||
imports bool
|
||||
@@ -33,8 +42,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
||||
} else {
|
||||
request.main.destination = normalized
|
||||
}
|
||||
request.effectiveVersion = effectiveSourceVersionRule(request.definition.Version, request.main.destination, request.sourceVersionMode)
|
||||
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem,
|
||||
SourceVersionMode: request.sourceVersionMode,
|
||||
}})
|
||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||
for groupIndex := range request.underlays {
|
||||
@@ -68,7 +79,7 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
||||
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||
groups := make([]*composedDocument, 0, len(request.underlays))
|
||||
for index, group := range request.underlays {
|
||||
composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting)
|
||||
composition, sources, contexts, diagnostics := acquireUnderlayGroupWithVersion(group, index+1, request.protectionVersion(), accounting, input.versionState)
|
||||
input.sources = append(input.sources, sources...)
|
||||
mergeResourceContexts(&input.contexts, contexts)
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -115,7 +126,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
selection := Source{kind: sourceBytes}
|
||||
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
|
||||
var diagnostics []Diagnostic
|
||||
input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.definition.Version, defaultImportLimits())
|
||||
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
@@ -127,7 +138,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
}
|
||||
if len(request.overrides) != 0 {
|
||||
var diagnostics []Diagnostic
|
||||
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.definition.Version, defaultImportLimits())
|
||||
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.protectionVersion(), defaultImportLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
@@ -138,6 +149,10 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
}
|
||||
|
||||
func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||
return acquireUnderlayGroupWithVersion(group, index, rule, accounting, nil)
|
||||
}
|
||||
|
||||
func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
||||
visited := make(map[resourceIdentity]importRoute)
|
||||
hostToken := &namespaceToken{}
|
||||
@@ -146,7 +161,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, contribution.destination, accounting, visited, hostToken)
|
||||
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
|
||||
for _, source := range acquired.sources {
|
||||
source.Layer = LayerUnderlay
|
||||
source.UnderlayIndex = index
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
// resolvedLoad carries typed phase outputs needed for validation and publication;
|
||||
// it owns no source access, options, or long-lived schema state.
|
||||
type resolvedLoad[T any] struct {
|
||||
configuration T
|
||||
decoded decodedConfiguration
|
||||
metadata Metadata
|
||||
final *document.Node
|
||||
snapshots []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
inheritance *inheritanceScope
|
||||
contexts map[string]ResourceContext
|
||||
configuration T
|
||||
decoded decodedConfiguration
|
||||
metadata Metadata
|
||||
final *document.Node
|
||||
snapshots []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
versionEvidence []*sourceVersionEvidence
|
||||
inheritance *inheritanceScope
|
||||
contexts map[string]ResourceContext
|
||||
}
|
||||
|
||||
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
|
||||
@@ -57,7 +58,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
final := decoded.final
|
||||
var resolutionSnapshots []resolutionSnapshot
|
||||
if definition.Resolve != nil {
|
||||
entry := newResolutionInput(metadata, decoded.final, input.sources, input.contexts)
|
||||
sources := input.sources
|
||||
if input.versionState != nil {
|
||||
sources = sourceInfosWithVersionEvidence(sources, input.versionState.evidence, nil, true)
|
||||
}
|
||||
entry := newResolutionInput(metadata, decoded.final, sources, input.contexts)
|
||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
|
||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||
if hasErrors(resolved.diagnostics) {
|
||||
@@ -73,7 +78,11 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
} else if input.document != nil {
|
||||
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
||||
}
|
||||
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
|
||||
var versionEvidence []*sourceVersionEvidence
|
||||
if input.versionState != nil {
|
||||
versionEvidence = append(versionEvidence, input.versionState.evidence...)
|
||||
}
|
||||
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
|
||||
}
|
||||
|
||||
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
||||
@@ -97,7 +106,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
||||
decoded, metadata := resolved.decoded, resolved.metadata
|
||||
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
||||
secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, decoded.final)
|
||||
sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false)
|
||||
retainedTrees := make([]*document.Node, 0, len(applications)+2)
|
||||
retainedTrees = append(retainedTrees, decoded.final, final)
|
||||
for _, application := range applications {
|
||||
@@ -105,7 +115,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
||||
}
|
||||
inspection := &Inspection{
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
sources: sources,
|
||||
contexts: cloneResourceContexts(resolved.contexts),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
|
||||
@@ -463,6 +463,41 @@ func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionWarningDoesNotSuppressReturnedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct{}
|
||||
versionPath := Keys("schema_version")
|
||||
result, err := testLoad(Bytes("version.yml", []byte("{}\n")), Options[config]{
|
||||
Definition: Definition[config]{Version: &VersionRule{
|
||||
Path: versionPath,
|
||||
Check: func(_ Version, validation *Validation) error {
|
||||
validation.Warning(versionPath, "review schema compatibility")
|
||||
return errors.New("CANARY_VERSION_ERROR")
|
||||
},
|
||||
}},
|
||||
})
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result = %#v, err = %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error type = %T", err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
var warning, hookFailure bool
|
||||
for _, diagnostic := range diagnostics {
|
||||
if strings.Contains(diagnostic.Message, "CANARY") {
|
||||
t.Fatalf("returned checker error text was exposed: %#v", diagnostic)
|
||||
}
|
||||
warning = warning || diagnostic.Code == CodeVersionWarning && diagnostic.Severity == SeverityWarning
|
||||
hookFailure = hookFailure || diagnostic.Code == CodeVersionHookFailed && diagnostic.Severity == SeverityError
|
||||
}
|
||||
if !warning || !hookFailure {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||
}
|
||||
}
|
||||
if options.SourceVersionMode != sourceVersionModeDefault && options.SourceVersionMode != sourceVersionModeIfPresent && options.SourceVersionMode != sourceVersionModeRequired {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid"))
|
||||
} else if options.SourceVersionMode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker"))
|
||||
}
|
||||
return options, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
|
||||
@@ -191,9 +191,19 @@ type SourceInfo struct {
|
||||
Kind SourceKind
|
||||
UnderlayIndex int
|
||||
ImportedFrom *Location
|
||||
VersionCheck *SourceVersionCheck
|
||||
namespace *namespaceToken
|
||||
}
|
||||
|
||||
// SourceVersionCheck is a detached, public-safe record of a source-local
|
||||
// version declaration observed during an enabled load.
|
||||
type SourceVersionCheck struct {
|
||||
Path Path
|
||||
Version Version
|
||||
Location *Location
|
||||
Redacted bool
|
||||
}
|
||||
|
||||
// Diagnostic is a safe structured problem report. Message and source labels must
|
||||
// never contain configuration values or other secrets.
|
||||
type Diagnostic struct {
|
||||
@@ -262,9 +272,20 @@ func cloneLocations(locations []Location) []Location {
|
||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
source.Destination = source.Destination.clone()
|
||||
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||
return source
|
||||
}
|
||||
|
||||
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
cloned.Path = value.Path.clone()
|
||||
cloned.Location = cloneLocation(value.Location)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
||||
if sources == nil {
|
||||
return nil
|
||||
|
||||
@@ -8,6 +8,10 @@ type secretDependency struct {
|
||||
}
|
||||
|
||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
||||
return classifySecretPathsWithEvidence(schema, predicate, metadata, final, applications, nil, projections...)
|
||||
}
|
||||
|
||||
func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, evidence []*sourceVersionEvidence, projections ...*document.Node) map[string]bool {
|
||||
paths := make(map[string]Path)
|
||||
for _, application := range applications {
|
||||
walkDocument(application.value, application.path, func(path Path, _ *document.Node) {
|
||||
@@ -71,17 +75,41 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
||||
classifyProjection(tree)
|
||||
}
|
||||
classifyProjection(final)
|
||||
for pointer, path := range paths {
|
||||
classifyPath := func(path Path) {
|
||||
if root, ok := schemaSecretRoot(schema, path); ok {
|
||||
secret[root.String()] = true
|
||||
}
|
||||
if predicate != nil && predicate(path.clone()) {
|
||||
secret[pointer] = true
|
||||
secret[path.String()] = true
|
||||
}
|
||||
}
|
||||
for _, path := range paths {
|
||||
classifyPath(path)
|
||||
}
|
||||
// Classify each original evidence shape before folding results into pointer
|
||||
// sensitivity. Numeric mapping keys and sequence indices can share a pointer.
|
||||
for _, item := range evidence {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
candidate := item.resolvedPath
|
||||
if !candidate.valid() {
|
||||
candidate = item.path
|
||||
}
|
||||
classifyPath(addSecretEvidencePath(paths, item.destination, candidate))
|
||||
for _, ancestor := range item.ancestors {
|
||||
classifyPath(addSecretEvidencePath(paths, item.destination, ancestor.path))
|
||||
}
|
||||
}
|
||||
return closeSecretDependencies(paths, secret, dependencies, inherited)
|
||||
}
|
||||
|
||||
func addSecretEvidencePath(paths map[string]Path, destination, local Path) Path {
|
||||
path := appendDestination(destination, local)
|
||||
addSecretPath(paths, path.String())
|
||||
return path
|
||||
}
|
||||
|
||||
func addSecretPath(paths map[string]Path, pointer string) {
|
||||
if existing, found := paths[pointer]; found && existing.valid() {
|
||||
// Accumulator keys already match their paths' canonical spelling. History
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
|
||||
for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} {
|
||||
@@ -77,3 +80,123 @@ func TestSecretPredicateReceivesDetachedHistoricalPath(t *testing.T) {
|
||||
t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceVersionEvidenceSensitivityUsesOriginalMountedPaths(t *testing.T) {
|
||||
const canary = "PRIVATE-VERSION-CANARY"
|
||||
type credential struct {
|
||||
Value string `yaml:"credential" configkit:"secret"`
|
||||
Public string `yaml:"public"`
|
||||
}
|
||||
type account struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
type tenant struct {
|
||||
Groups map[string][]credential `yaml:"groups"`
|
||||
Account account `yaml:"account" configkit:"secret"`
|
||||
Settings map[string]string `yaml:"settings"`
|
||||
Labels map[string]string `yaml:"labels"`
|
||||
}
|
||||
type configuration struct {
|
||||
Tenant tenant `yaml:"tenant"`
|
||||
}
|
||||
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
|
||||
if issue != nil {
|
||||
t.Fatal(issue)
|
||||
}
|
||||
|
||||
destination := Keys("tenant")
|
||||
sequencePath := Keys("groups").Key("blue").Index(2).Key("credential")
|
||||
accountPath := Keys("account", "token")
|
||||
predicatePath := Keys("settings", "private")
|
||||
nullPath := Keys("settings", "nullable")
|
||||
absentPath := Keys("settings", "missing")
|
||||
publicPath := Keys("labels", "public")
|
||||
evidence := []*sourceVersionEvidence{
|
||||
{
|
||||
path: sequencePath.clone(), resolvedPath: sequencePath.clone(), destination: destination.clone(),
|
||||
version: Version{Present: true, Text: canary},
|
||||
ancestors: []sourceVersionAncestor{{}, {path: Keys("groups")}, {path: Keys("groups").Key("blue")}, {path: Keys("groups").Key("blue").Index(2)}, {path: sequencePath.clone()}},
|
||||
},
|
||||
{
|
||||
path: accountPath.clone(), resolvedPath: accountPath.clone(), destination: destination.clone(),
|
||||
version: Version{Present: true, Text: canary},
|
||||
ancestors: []sourceVersionAncestor{{}, {path: Keys("account")}, {path: accountPath.clone()}},
|
||||
},
|
||||
{path: predicatePath.clone(), resolvedPath: predicatePath.clone(), destination: destination.clone(), version: Version{Present: true, Text: canary}},
|
||||
{path: nullPath.clone(), resolvedPath: nullPath.clone(), destination: destination.clone(), version: Version{Present: true, Null: true}},
|
||||
{path: absentPath.clone(), resolvedPath: absentPath.clone(), destination: destination.clone()},
|
||||
{path: publicPath.clone(), resolvedPath: publicPath.clone(), destination: destination.clone(), version: Version{Present: true, Text: canary}},
|
||||
}
|
||||
|
||||
final := parseCompositionDocument(t, "final", "tenant:\n groups:\n blue:\n - public: shifted-neighbor\n labels:\n public: visible\n")
|
||||
var sawOriginalSequencePath, sawAncestorPredicate bool
|
||||
classified := classifySecretPathsWithEvidence(schema, func(path Path) bool {
|
||||
if path.String() == appendDestination(destination, predicatePath).String() {
|
||||
return true
|
||||
}
|
||||
if path.String() == appendDestination(destination, nullPath).String() {
|
||||
return true
|
||||
}
|
||||
if path.String() == appendDestination(destination, absentPath).String() {
|
||||
return true
|
||||
}
|
||||
if path.String() == appendDestination(destination, Keys("account")).String() {
|
||||
sawAncestorPredicate = true
|
||||
return true
|
||||
}
|
||||
if path.String() == appendDestination(destination, sequencePath).String() {
|
||||
sawOriginalSequencePath = len(path.segments) == 5 && path.segments[3].kind == pathSegmentIndex && path.segments[3].index == 2
|
||||
}
|
||||
return false
|
||||
}, Metadata{}, final, []historyApplication{{path: destination, value: final}}, evidence)
|
||||
|
||||
for _, path := range []Path{
|
||||
appendDestination(destination, sequencePath),
|
||||
appendDestination(destination, Keys("account")),
|
||||
appendDestination(destination, predicatePath),
|
||||
appendDestination(destination, nullPath),
|
||||
appendDestination(destination, absentPath),
|
||||
} {
|
||||
if !classified[path.String()] {
|
||||
t.Errorf("evidence path %s was not classified: %#v", path, classified)
|
||||
}
|
||||
}
|
||||
shiftedNeighbor := appendDestination(destination, Keys("groups").Key("blue").Index(0).Key("public"))
|
||||
if classified[shiftedNeighbor.String()] {
|
||||
t.Fatalf("shifted neighboring payload was classified from declaration evidence: %#v", classified)
|
||||
}
|
||||
if classified[appendDestination(destination, publicPath).String()] || !sawOriginalSequencePath || !sawAncestorPredicate {
|
||||
t.Fatalf("unclassified or semantic evidence candidates = %#v; original path seen %v, ancestor predicate seen %v", classified, sawOriginalSequencePath, sawAncestorPredicate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceVersionEvidenceClassificationKeepsCollidingShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
type header struct {
|
||||
Version string `yaml:"version" configkit:"secret"`
|
||||
}
|
||||
type configuration struct {
|
||||
Meta map[string]header `yaml:"meta"`
|
||||
}
|
||||
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
|
||||
if issue != nil {
|
||||
t.Fatal(issue)
|
||||
}
|
||||
keyPath := Keys("meta", "0", "version")
|
||||
indexPath := Keys("meta").Index(0).Key("version")
|
||||
for _, reverse := range []bool{false, true} {
|
||||
evidence := []*sourceVersionEvidence{{resolvedPath: keyPath}, {resolvedPath: indexPath}}
|
||||
if reverse {
|
||||
evidence[0], evidence[1] = evidence[1], evidence[0]
|
||||
}
|
||||
var sawKey, sawIndex bool
|
||||
classified := classifySecretPathsWithEvidence(schema, func(path Path) bool {
|
||||
sawKey = sawKey || reflect.DeepEqual(path, keyPath)
|
||||
sawIndex = sawIndex || reflect.DeepEqual(path, indexPath)
|
||||
return false
|
||||
}, Metadata{}, nil, nil, evidence)
|
||||
if !classified[keyPath.String()] || !sawKey || !sawIndex {
|
||||
t.Errorf("reverse=%v: classified=%v, key seen=%v, index seen=%v", reverse, classified, sawKey, sawIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ func loadSelectedOverlayDocument(source Source, context *FilesystemContext, main
|
||||
}
|
||||
|
||||
func loadSelectedOverlayInto(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
||||
return loadSelectedOverlayIntoWithVersion(source, context, main, base, sources, name, rule, limits, nil)
|
||||
}
|
||||
|
||||
func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContext, main parsedSource, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) {
|
||||
if nativeContext(source, context) {
|
||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||
if diagnostic != nil {
|
||||
@@ -46,7 +50,7 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
||||
if diagnostic := validateNativeDirectories(access, basePath, mainPath); diagnostic != nil {
|
||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||
}
|
||||
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits)
|
||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
|
||||
}
|
||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
@@ -58,10 +62,14 @@ func loadSelectedOverlayInto(source Source, context *FilesystemContext, main par
|
||||
value.Primary = nil
|
||||
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||
}
|
||||
return loadSelectedOverlayWithAccess(access, base, sources, name, rule, limits)
|
||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState)
|
||||
}
|
||||
|
||||
func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) ([]SourceInfo, []Diagnostic) {
|
||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, nil)
|
||||
}
|
||||
|
||||
func loadSelectedOverlayWithAccessAndVersion(access *filesystemAccess, base *assemblyAccumulator, sources []SourceInfo, name string, rule *VersionRule, limits importLimits, versionState *sourceVersionState) ([]SourceInfo, []Diagnostic) {
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
@@ -126,7 +134,15 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
|
||||
if diagnostics := overlayDirectiveDiagnostics(overlayRoot, Path{}); len(diagnostics) != 0 {
|
||||
return sources, diagnostics
|
||||
}
|
||||
if versionNode := fragmentVersionClaim(overlayRoot, rule); versionNode != nil {
|
||||
overlayPayload := overlayRoot
|
||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||
processed, failures := versionState.process(overlayRoot, sourceVersionContribution, Path{}, sourceID)
|
||||
if len(failures) != 0 {
|
||||
return sources, failures
|
||||
}
|
||||
overlayPayload = processed.payload
|
||||
}
|
||||
if versionNode := fragmentVersionClaim(overlayPayload, rule); versionNode != nil {
|
||||
location := publicLocation(versionNode.Location)
|
||||
return sources, []Diagnostic{{
|
||||
Code: CodeOverlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
@@ -139,7 +155,7 @@ func loadSelectedOverlayWithAccess(access *filesystemAccess, base *assemblyAccum
|
||||
return sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected overlay", nil)}
|
||||
}
|
||||
|
||||
if diagnostics := base.applyOverlay(overlayRoot, nil, nil, LayerOverlay); len(diagnostics) != 0 {
|
||||
if diagnostics := base.applyOverlay(overlayPayload, nil, nil, LayerOverlay); len(diagnostics) != 0 {
|
||||
return sources, diagnostics
|
||||
}
|
||||
base.current.inputBytes += int64(len(data))
|
||||
|
||||
433
internal/engine/source_version.go
Normal file
433
internal/engine/source_version.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type sourceVersionMode uint8
|
||||
|
||||
const (
|
||||
sourceVersionModeDefault sourceVersionMode = iota
|
||||
sourceVersionModeIfPresent
|
||||
sourceVersionModeRequired
|
||||
)
|
||||
|
||||
// SourceVersionModeFromFacade converts the root-owned public mode while
|
||||
// keeping the engine's policy type private. Unknown values remain intact for
|
||||
// pre-acquisition validation.
|
||||
func SourceVersionModeFromFacade(value uint8) sourceVersionMode {
|
||||
return sourceVersionMode(value)
|
||||
}
|
||||
|
||||
type sourceVersionRole uint8
|
||||
|
||||
const (
|
||||
sourceVersionMain sourceVersionRole = iota
|
||||
sourceVersionContribution
|
||||
)
|
||||
|
||||
type sourceVersionAncestor struct {
|
||||
path Path
|
||||
kind document.Kind
|
||||
}
|
||||
|
||||
type sourceVersionEvidence struct {
|
||||
sourceID string
|
||||
path Path
|
||||
resolvedPath Path
|
||||
destination Path
|
||||
version Version
|
||||
location *Location
|
||||
ancestors []sourceVersionAncestor
|
||||
absence sourceVersionPathAbsence
|
||||
checked bool
|
||||
redacted bool
|
||||
}
|
||||
|
||||
type sourceVersionState struct {
|
||||
localRule *VersionRule
|
||||
effectiveRule *VersionRule
|
||||
mode sourceVersionMode
|
||||
mainProcessing *sourceVersionProcessingResult
|
||||
evidence []*sourceVersionEvidence
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
func sourceInfosWithVersionEvidence(sources []SourceInfo, evidence []*sourceVersionEvidence, secrets map[string]bool, conservative bool) []SourceInfo {
|
||||
result := cloneSourceInfos(sources)
|
||||
if len(evidence) == 0 {
|
||||
return result
|
||||
}
|
||||
sensitivity := newDiffSensitivity(secrets, nil)
|
||||
for _, item := range evidence {
|
||||
if item == nil || item.sourceID == "" {
|
||||
continue
|
||||
}
|
||||
for index := range result {
|
||||
if result[index].ID == item.sourceID {
|
||||
result[index].VersionCheck = sourceVersionCheckForEvidence(item, sensitivity, conservative)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sourceVersionCheckForEvidence(evidence *sourceVersionEvidence, sensitivity *diffSensitivity, conservative bool) *SourceVersionCheck {
|
||||
path := evidence.resolvedPath
|
||||
if !path.valid() {
|
||||
path = evidence.path
|
||||
}
|
||||
version := evidence.version
|
||||
redacted := evidence.redacted
|
||||
if conservative {
|
||||
redacted = redacted || version.Text != ""
|
||||
} else if sensitivity.isSensitiveAtOrAbove(appendDestination(evidence.destination, path).String()) {
|
||||
redacted = true
|
||||
}
|
||||
if redacted {
|
||||
version.Text = ""
|
||||
}
|
||||
return &SourceVersionCheck{
|
||||
Path: path.clone(), Version: version, Location: cloneLocation(evidence.location), Redacted: redacted,
|
||||
}
|
||||
}
|
||||
|
||||
func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string) (sourceVersionProcessingResult, []Diagnostic) {
|
||||
if state == nil || state.mode == sourceVersionModeDefault {
|
||||
return sourceVersionProcessingResult{}, nil
|
||||
}
|
||||
result := processSourceVersion(root, state.localRule, role, destination, state.mode)
|
||||
if result.evidence != nil {
|
||||
result.evidence.sourceID = sourceID
|
||||
state.evidence = append(state.evidence, result.evidence)
|
||||
}
|
||||
var failures []Diagnostic
|
||||
for _, diagnostic := range result.diagnostics {
|
||||
if diagnostic.Severity == SeverityWarning {
|
||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||
} else if diagnostic.Severity == SeverityError {
|
||||
failures = append(failures, diagnostic)
|
||||
}
|
||||
}
|
||||
return result, failures
|
||||
}
|
||||
|
||||
type sourceVersionProcessingResult struct {
|
||||
payload *document.Node
|
||||
imports []importDeclaration
|
||||
evidence *sourceVersionEvidence
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule {
|
||||
if local == nil {
|
||||
return nil
|
||||
}
|
||||
if mode == sourceVersionModeDefault {
|
||||
return local
|
||||
}
|
||||
effective := *local
|
||||
effective.Path = Path{
|
||||
segments: append(clonePathSegments(destination.segments, len(local.Path.segments)), local.Path.segments...),
|
||||
invalid: destination.invalid || local.Path.invalid,
|
||||
}
|
||||
return &effective
|
||||
}
|
||||
|
||||
type sourceVersionPathAbsence uint8
|
||||
|
||||
const (
|
||||
sourceVersionPathPresent sourceVersionPathAbsence = iota
|
||||
sourceVersionPathInvalid
|
||||
sourceVersionMemberMissing
|
||||
sourceVersionIndexUnavailable
|
||||
sourceVersionPathObstructed
|
||||
)
|
||||
|
||||
type sourceVersionPath struct {
|
||||
node *document.Node
|
||||
present bool
|
||||
absence sourceVersionPathAbsence
|
||||
nearest document.Location
|
||||
resolvedPath Path
|
||||
ancestors []sourceVersionAncestor
|
||||
}
|
||||
|
||||
func processSourceVersion(root *document.Node, rule *VersionRule, role sourceVersionRole, destination Path, mode sourceVersionMode) sourceVersionProcessingResult {
|
||||
if role != sourceVersionMain && role != sourceVersionContribution {
|
||||
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source role is invalid")}}
|
||||
}
|
||||
if mode != sourceVersionModeDefault && mode != sourceVersionModeIfPresent && mode != sourceVersionModeRequired {
|
||||
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source-version mode is invalid")}}
|
||||
}
|
||||
if mode != sourceVersionModeDefault && (rule == nil || rule.Check == nil || !rule.Path.valid()) {
|
||||
return sourceVersionProcessingResult{diagnostics: []Diagnostic{sourceVersionOptionsDiagnostic("source-version checking requires a valid version rule and checker")}}
|
||||
}
|
||||
if root == nil {
|
||||
if role == sourceVersionMain && mode != sourceVersionModeDefault {
|
||||
return sourceVersionProcessingResult{diagnostics: canonicalDiagnostics(reportVersion(rule, Version{}, nil))}
|
||||
}
|
||||
return sourceVersionProcessingResult{}
|
||||
}
|
||||
|
||||
payload, imports, diagnostics := splitImportDirectives(root)
|
||||
if len(diagnostics) != 0 {
|
||||
return sourceVersionProcessingResult{diagnostics: diagnostics}
|
||||
}
|
||||
result := sourceVersionProcessingResult{payload: payload, imports: imports}
|
||||
if mode == sourceVersionModeDefault {
|
||||
return result
|
||||
}
|
||||
|
||||
resolved := resolveSourceVersionPath(root, rule.Path)
|
||||
evidence := &sourceVersionEvidence{
|
||||
path: rule.Path.clone(), resolvedPath: resolved.resolvedPath,
|
||||
destination: destination.clone(), ancestors: resolved.ancestors,
|
||||
absence: resolved.absence,
|
||||
}
|
||||
location := resolved.nearest
|
||||
if resolved.present {
|
||||
location = resolved.node.Location
|
||||
}
|
||||
if locationKnown(location) {
|
||||
public := publicLocation(location)
|
||||
evidence.location = &public
|
||||
}
|
||||
result.evidence = evidence
|
||||
|
||||
interpreted := interpretVersion(rule, root)
|
||||
evidence.version = interpreted.version
|
||||
if resolved.present && sourceVersionControlOverlap(root, evidence.resolvedPath) {
|
||||
evidence.redactText()
|
||||
result.payload = nil
|
||||
result.diagnostics = []Diagnostic{sourceVersionOverlapDiagnostic(rule, evidence)}
|
||||
return result
|
||||
}
|
||||
if hasErrors(interpreted.diagnostics) {
|
||||
evidence.redactText()
|
||||
result.payload = nil
|
||||
result.diagnostics = canonicalDiagnostics(interpreted.diagnostics)
|
||||
return result
|
||||
}
|
||||
if !interpreted.version.Present {
|
||||
if mode == sourceVersionModeRequired {
|
||||
evidence.redactText()
|
||||
result.payload = nil
|
||||
result.diagnostics = []Diagnostic{sourceVersionMissingDiagnostic(rule, evidence)}
|
||||
return result
|
||||
}
|
||||
if role == sourceVersionContribution {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
evidence.checked = true
|
||||
result.diagnostics = canonicalDiagnostics(reportVersion(rule, interpreted.version, root))
|
||||
if hasErrors(result.diagnostics) {
|
||||
evidence.redactText()
|
||||
result.payload = nil
|
||||
return result
|
||||
}
|
||||
if role == sourceVersionContribution && interpreted.version.Present {
|
||||
if !removeSourceVersionNode(result.payload, evidence.resolvedPath) {
|
||||
evidence.redactText()
|
||||
result.payload = nil
|
||||
result.diagnostics = []Diagnostic{sourceVersionOptionsDiagnostic("selected source version could not be removed from its owned payload")}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sourceVersionOptionsDiagnostic(message string) Diagnostic {
|
||||
return Diagnostic{Code: CodeOptionsInvalid, Severity: SeverityError, Message: message}
|
||||
}
|
||||
|
||||
func sourceVersionMissingDiagnostic(rule *VersionRule, evidence *sourceVersionEvidence) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: CodeVersionError, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "required schema version is missing", Primary: cloneLocation(evidence.location),
|
||||
}
|
||||
}
|
||||
|
||||
func sourceVersionOverlapDiagnostic(rule *VersionRule, evidence *sourceVersionEvidence) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: CodeVersionError, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema version path overlaps a structural directive", Primary: cloneLocation(evidence.location),
|
||||
}
|
||||
}
|
||||
|
||||
func (evidence *sourceVersionEvidence) redactText() {
|
||||
if evidence == nil {
|
||||
return
|
||||
}
|
||||
evidence.version.Text = ""
|
||||
evidence.redacted = true
|
||||
}
|
||||
|
||||
func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath {
|
||||
// Refine only segments whose container shape is known, keeping the full
|
||||
// requested suffix when lookup stops before the declaration.
|
||||
resolved := sourceVersionPath{absence: sourceVersionPathInvalid, resolvedPath: path.clone()}
|
||||
if root == nil || !path.valid() {
|
||||
return resolved
|
||||
}
|
||||
current := root
|
||||
resolved.nearest = current.Location
|
||||
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind})
|
||||
for position, segment := range path.segments {
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
resolved.absence = sourceVersionPathObstructed
|
||||
return resolved
|
||||
}
|
||||
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentKey, key: segment.key}
|
||||
found := false
|
||||
for _, entry := range current.Mapping {
|
||||
if entry.Key == segment.key {
|
||||
current = entry.Value
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
resolved.absence = sourceVersionMemberMissing
|
||||
return resolved
|
||||
}
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok {
|
||||
resolved.absence = sourceVersionIndexUnavailable
|
||||
return resolved
|
||||
}
|
||||
resolved.resolvedPath.segments[position] = pathSegment{kind: pathSegmentIndex, index: index}
|
||||
if index >= len(current.Sequence) {
|
||||
resolved.absence = sourceVersionIndexUnavailable
|
||||
return resolved
|
||||
}
|
||||
current = current.Sequence[index]
|
||||
default:
|
||||
resolved.absence = sourceVersionPathObstructed
|
||||
return resolved
|
||||
}
|
||||
resolved.nearest = current.Location
|
||||
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{
|
||||
path: Path{segments: clonePathSegments(resolved.resolvedPath.segments[:position+1], 0)}, kind: current.Kind,
|
||||
})
|
||||
}
|
||||
resolved.node = current
|
||||
resolved.present = true
|
||||
resolved.absence = sourceVersionPathPresent
|
||||
return resolved
|
||||
}
|
||||
|
||||
func sourceVersionControlOverlap(root *document.Node, path Path) bool {
|
||||
if root == nil || !path.valid() {
|
||||
return false
|
||||
}
|
||||
if root.Kind == document.Mapping {
|
||||
for _, entry := range root.Mapping {
|
||||
if entry.Key == directiveKey && pathHasPrefix(path, Keys(directiveKey)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sourceVersionInheritanceOverlap(root, Path{}, path)
|
||||
}
|
||||
|
||||
func sourceVersionInheritanceOverlap(node *document.Node, current, path Path) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
if node.Kind == document.Mapping {
|
||||
if _, found := inheritanceDirective(node); found {
|
||||
controlPath := current.Key(inheritanceKey)
|
||||
if pathHasPrefix(path, controlPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if entry.Key == inheritanceKey {
|
||||
continue
|
||||
}
|
||||
if sourceVersionInheritanceOverlap(entry.Value, current.Key(entry.Key), path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if sourceVersionInheritanceOverlap(child, current.Index(index), path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pathHasPrefix(path, prefix Path) bool {
|
||||
if !path.valid() || !prefix.valid() || len(prefix.segments) > len(path.segments) {
|
||||
return false
|
||||
}
|
||||
for index, segment := range prefix.segments {
|
||||
candidate := path.segments[index]
|
||||
if candidate.kind != segment.kind || candidate.key != segment.key || candidate.index != segment.index {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func removeSourceVersionNode(root *document.Node, path Path) bool {
|
||||
if root == nil || !path.valid() || len(path.segments) == 0 {
|
||||
return false
|
||||
}
|
||||
current := root
|
||||
for _, segment := range path.segments[:len(path.segments)-1] {
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
for _, entry := range current.Mapping {
|
||||
if entry.Key == segment.key {
|
||||
current = entry.Value
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index >= len(current.Sequence) {
|
||||
return false
|
||||
}
|
||||
current = current.Sequence[index]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
last := path.segments[len(path.segments)-1]
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if last.kind == pathSegmentIndex {
|
||||
return false
|
||||
}
|
||||
for index, entry := range current.Mapping {
|
||||
if entry.Key == last.key {
|
||||
current.Mapping = append(current.Mapping[:index], current.Mapping[index+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(last)
|
||||
if !ok || index >= len(current.Sequence) {
|
||||
return false
|
||||
}
|
||||
current.Sequence = append(current.Sequence[:index], current.Sequence[index+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
1332
internal/engine/source_version_test.go
Normal file
1332
internal/engine/source_version_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -187,34 +187,59 @@ func validationValueMutations(before, after *document.Node) []Diagnostic {
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
type versionCheckResult struct {
|
||||
version Version
|
||||
diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||
return checkVersionResult(rule, root).diagnostics
|
||||
}
|
||||
|
||||
func checkVersionResult(rule *VersionRule, root *document.Node) versionCheckResult {
|
||||
if rule == nil {
|
||||
return nil
|
||||
return versionCheckResult{}
|
||||
}
|
||||
version := Version{}
|
||||
result := interpretVersion(rule, root)
|
||||
if hasErrors(result.diagnostics) {
|
||||
result.diagnostics = canonicalDiagnostics(result.diagnostics)
|
||||
return result
|
||||
}
|
||||
result.diagnostics = append(result.diagnostics, reportVersion(rule, result.version, root)...)
|
||||
result.diagnostics = canonicalDiagnostics(result.diagnostics)
|
||||
return result
|
||||
}
|
||||
|
||||
func interpretVersion(rule *VersionRule, root *document.Node) versionCheckResult {
|
||||
result := versionCheckResult{}
|
||||
node, present := documentAtPath(root, rule.Path)
|
||||
if present {
|
||||
version.Present = true
|
||||
result.version.Present = true
|
||||
switch node.Kind {
|
||||
case document.Null:
|
||||
version.Null = true
|
||||
result.version.Null = true
|
||||
case document.String:
|
||||
version.Text = node.Text
|
||||
result.version.Text = node.Text
|
||||
case document.Number:
|
||||
if isIntegerVersion(node) {
|
||||
version.Text = node.Text
|
||||
break
|
||||
result.version.Text = node.Text
|
||||
return result
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||
primary := documentLocationPointer(location)
|
||||
return []Diagnostic{{
|
||||
result.diagnostics = []Diagnostic{{
|
||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema version must be a string, integer, or null", Primary: primary,
|
||||
}}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func reportVersion(rule *VersionRule, version Version, root *document.Node) []Diagnostic {
|
||||
state := &validationState{
|
||||
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
||||
invalidPath: CodeVersionError, document: root,
|
||||
@@ -229,7 +254,7 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
||||
})
|
||||
}
|
||||
return canonicalDiagnostics(state.diagnostics)
|
||||
return state.diagnostics
|
||||
}
|
||||
|
||||
func isIntegerVersion(node *document.Node) bool {
|
||||
|
||||
15
layered.go
15
layered.go
@@ -8,8 +8,9 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
// reader ownership rules.
|
||||
type Input struct {
|
||||
Source Source
|
||||
// Destination selects the mapping path where Source and its imported
|
||||
// application contributions are placed. Its zero value keeps root placement.
|
||||
// Destination establishes the mapping path for Source. Imports inherit this
|
||||
// path or append a relative destination declared in YAML. Its zero value
|
||||
// keeps root placement.
|
||||
Destination Path
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
@@ -22,8 +23,14 @@ type Underlay struct{ Inputs []Input }
|
||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
|
||||
// ignores it. Overrides remain explicit CLI records, not general mappings.
|
||||
// SourceVersionMode applies one declaration policy to main, underlay, import,
|
||||
// and selected overlay documents. Enabled modes interpret the version path
|
||||
// source-locally, consume accepted non-main declarations, and protect main's
|
||||
// destination-prefixed path. The zero value preserves existing behavior; see
|
||||
// the shared source-version contract in docs/config.md.
|
||||
type LayeredOptions[T any] struct {
|
||||
Definition Definition[T]
|
||||
SourceVersionMode SourceVersionMode
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
OverlayFilesystem *FilesystemContext
|
||||
@@ -56,7 +63,9 @@ func toEngineInput(input Input) engine.Input {
|
||||
|
||||
func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] {
|
||||
result := engine.LayeredOptions[T]{
|
||||
Definition: toEngineDefinition(options.Definition), Overlay: options.Overlay,
|
||||
Definition: toEngineDefinition(options.Definition),
|
||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||
Overlay: options.Overlay,
|
||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||
}
|
||||
if options.Underlays != nil {
|
||||
|
||||
49
lifecycle.go
49
lifecycle.go
@@ -17,15 +17,42 @@ type Definition[T any] struct {
|
||||
Secret func(Path) bool
|
||||
}
|
||||
|
||||
// SourceVersionMode controls schema-version declarations across one complete
|
||||
// load. The zero value preserves the existing main-only effective-path check
|
||||
// and its protected-path behavior. Enabled modes check participating documents
|
||||
// at source-local Definition.Version.Path coordinates and consume accepted
|
||||
// non-main declarations. See the shared source-version contract in docs/config.md.
|
||||
type SourceVersionMode uint8
|
||||
|
||||
const (
|
||||
// SourceVersionModeDefault preserves the existing main-version check and
|
||||
// rejects version-path contributions from imported, underlay, and overlay
|
||||
// payloads.
|
||||
SourceVersionModeDefault SourceVersionMode = iota
|
||||
// SourceVersionModeIfPresent checks each participating document that has a
|
||||
// declaration. A missing main declaration still reaches the checker; an
|
||||
// absent non-main declaration skips it. Accepted non-main declarations are
|
||||
// consumed before composition.
|
||||
SourceVersionModeIfPresent
|
||||
// SourceVersionModeRequired requires a declaration in each participating
|
||||
// document. NoSource retains the existing absent-main checker behavior.
|
||||
// Accepted non-main declarations are consumed before composition.
|
||||
SourceVersionModeRequired
|
||||
)
|
||||
|
||||
// Options contains the application definition and per-load input selections.
|
||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||
// Overrides contains only values explicitly supplied by an application CLI.
|
||||
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||
// document's local coordinates. Main protection remains at that path prefixed
|
||||
// by the main input's Destination.
|
||||
type Options[T any] struct {
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
SourceVersionMode SourceVersionMode
|
||||
}
|
||||
|
||||
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
||||
@@ -53,7 +80,10 @@ type Override struct {
|
||||
type Resolution struct{ value *engine.Resolution }
|
||||
|
||||
// VersionRule declares the input path and application checker for a schema version.
|
||||
// At that path, string and decimal-integer input is supplied as Version.Text.
|
||||
// Default source-version mode interprets Path as the existing effective path;
|
||||
// enabled modes interpret it in each participating document's local coordinates.
|
||||
// At the selected path, strings and strict decimal integers supply their exact
|
||||
// spelling as Version.Text. See docs/config.md for the complete contract.
|
||||
type VersionRule struct {
|
||||
Path Path
|
||||
Check func(Version, *Validation) error
|
||||
@@ -226,9 +256,10 @@ func (v *Validation) engineContext() *engine.Validation {
|
||||
|
||||
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
||||
result := engine.Options[T]{
|
||||
Definition: toEngineDefinition(options.Definition),
|
||||
Imports: options.Imports,
|
||||
Overlay: options.Overlay,
|
||||
Definition: toEngineDefinition(options.Definition),
|
||||
Imports: options.Imports,
|
||||
Overlay: options.Overlay,
|
||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||
}
|
||||
if options.Filesystem != nil {
|
||||
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
||||
|
||||
36
records.go
36
records.go
@@ -189,13 +189,34 @@ type SourceInfo struct {
|
||||
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 is the effective mapping path. Imports inherit their declaring
|
||||
// document's path unless they append a YAML destination; overlay and CLI
|
||||
// sources use the root path.
|
||||
Destination Path
|
||||
Layer Layer
|
||||
Kind SourceKind
|
||||
UnderlayIndex int
|
||||
ImportedFrom *Location
|
||||
// VersionCheck is sanitized evidence for source-local declaration processing
|
||||
// in an enabled source-version load. It is nil when that processing did not
|
||||
// occur, including default mode and synthetic CLI sources. During callbacks
|
||||
// and failed loads, text is conservatively withheld; successful final source
|
||||
// records reflect completed sensitivity classification.
|
||||
VersionCheck *SourceVersionCheck
|
||||
}
|
||||
|
||||
// SourceVersionCheck describes a source-local version declaration observed
|
||||
// during an enabled load. A non-nil record with Present false records observed
|
||||
// absence, but does not imply that a checker ran or accepted a value. Text is
|
||||
// empty when sensitivity classification or conservative callback-time/failed-
|
||||
// load redaction withholds it; Present and Null retain their meaning. Copies
|
||||
// returned by source queries are detached. See docs/config.md for disclosure
|
||||
// and lifecycle details.
|
||||
type SourceVersionCheck struct {
|
||||
Path Path
|
||||
Version Version
|
||||
Location *Location
|
||||
Redacted bool
|
||||
}
|
||||
|
||||
// Diagnostic is a safe structured problem report. Message and source labels must
|
||||
@@ -266,9 +287,20 @@ func cloneLocations(locations []Location) []Location {
|
||||
func cloneSourceInfo(source SourceInfo) SourceInfo {
|
||||
source.Destination = source.Destination.clone()
|
||||
source.ImportedFrom = cloneLocation(source.ImportedFrom)
|
||||
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
|
||||
return source
|
||||
}
|
||||
|
||||
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
cloned.Path = value.Path.clone()
|
||||
cloned.Location = cloneLocation(value.Location)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
|
||||
if sources == nil {
|
||||
return nil
|
||||
|
||||
@@ -38,3 +38,29 @@ func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
||||
t.Fatalf("source record destination changed through clone: %q", converted.Destination.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
|
||||
path := Keys("records").Index(2).Key("version")
|
||||
source := SourceInfo{
|
||||
ID: "source-1",
|
||||
VersionCheck: &SourceVersionCheck{
|
||||
Path: path, Version: Version{Present: true, Text: "123"},
|
||||
Location: &Location{SourceID: "source-1", Line: 4, Column: 8},
|
||||
},
|
||||
}
|
||||
internal := toEngineSourceInfo(source)
|
||||
if internal.VersionCheck == nil || internal.VersionCheck.Path.String() != path.String() ||
|
||||
internal.VersionCheck.Version.Text != "123" || internal.VersionCheck.Location == nil || internal.VersionCheck.Location.Line != 4 {
|
||||
t.Fatalf("engine source version check = %#v", internal.VersionCheck)
|
||||
}
|
||||
converted := fromEngineSourceInfo(internal)
|
||||
copy := cloneSourceInfo(converted)
|
||||
copy.VersionCheck.Path = Keys("changed")
|
||||
copy.VersionCheck.Version.Text = "changed"
|
||||
copy.VersionCheck.Location.Line = 99
|
||||
copy.VersionCheck.Redacted = true
|
||||
if converted.VersionCheck.Path.String() != path.String() || converted.VersionCheck.Version.Text != "123" ||
|
||||
converted.VersionCheck.Location.Line != 4 || converted.VersionCheck.Redacted {
|
||||
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user