Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21c9bc0ad0 | |||
| 68400ffdfb | |||
| 5d5d51ab11 | |||
| 69bd9ead53 | |||
| 2ff588eb64 | |||
| c96197c417 | |||
| 24ab623cb2 | |||
| 1ce147c9f6 | |||
| 9833ca3aa3 | |||
| eafbd32489 | |||
| 9ac0053590 | |||
| 791cf16136 | |||
| 31ae30372e | |||
| 7ee9e92ef5 | |||
| 556b10b2cd | |||
| 07e4ae5d92 | |||
| 7548fa101a | |||
| 197567177e | |||
| 7330107389 | |||
| ad6d3748d9 | |||
| 1ee508ba02 | |||
| e9155fced6 | |||
| 14c83a6515 | |||
| 3ebb848ead | |||
| 4289f9ff7f | |||
| a050986916 | |||
| fb71421956 | |||
| b281adb751 | |||
| 1448f9b19d | |||
| 731149fc6d | |||
| 9ff873f54c | |||
| 3f389662e9 | |||
| 906ca34b6d | |||
| 17216a40f3 | |||
| 896c1cde08 | |||
| 91769e9f9b | |||
| 98c8b7c842 | |||
| 1954052631 | |||
| 3ffaef7ede | |||
| 6a1447c327 | |||
| 12fd0a7191 | |||
| 8155465322 | |||
| 408dd13c24 | |||
| 738c8a9cc7 | |||
| 4b4e15fc58 | |||
| 76521f7cd8 | |||
| cacc558288 | |||
| 231ae66857 | |||
| da74d4190d | |||
| 11f7154874 | |||
| 3828b117e8 | |||
| 2419e63717 | |||
| a893c7716c | |||
| 28ef9b6200 | |||
| 0dd16aa8a9 | |||
| f6d54a094a | |||
| f9ec66e867 | |||
| be5cdeb502 | |||
| 1f64673d0d | |||
| 01b08e76a3 | |||
| 453da684d7 | |||
| fb6c254d38 | |||
| a71413ce67 | |||
| d66338a1c5 | |||
| 643f43ce6a | |||
| c25cf39d19 | |||
| 7d18c93339 | |||
| b1d89810ca | |||
| bb5540b62e | |||
| 80cf23e710 | |||
| ac48557535 | |||
| b4af900c10 | |||
| f132cd6468 | |||
| 75e209eb3b | |||
| 1dd94a3cc6 | |||
| d172a5e46a | |||
| 3a1b6ff355 | |||
| 68383f34de | |||
| 549fca239d | |||
| 883244936c | |||
| a8ae2e9d5e | |||
| f18582106b | |||
| d8bf6abc2b | |||
| 4bf940c579 | |||
| 4fd3bee986 | |||
| 6f6130fd12 | |||
| 3122e2f32d | |||
| e4a5ecb4be | |||
| 826062aa17 | |||
| 08f2a8f3e9 | |||
| 12d350a292 | |||
| 4fcff5f453 | |||
| 247a12bc50 | |||
| 119b42c810 | |||
| 0f9ecf12ed | |||
| 7bf2f0c687 | |||
| 8aba43da38 | |||
| 877c497325 | |||
| 08a8c25542 | |||
| ea27f17f5c | |||
| ae3408a096 | |||
| deb9aa5c69 | |||
| 2736ddfbf2 | |||
| 7889ebddfb | |||
| 4a5316b7fc | |||
| e1e94eeec7 | |||
| 176e9a5d90 | |||
| 23c1976ef1 | |||
| aab79d9508 | |||
| ecd5d5d460 | |||
| b1c278b38b | |||
| eb5a82f1f3 | |||
| b30c9f167d | |||
| 5925066d35 | |||
| 0cca633a67 | |||
| a80273433e | |||
| 320d0c5f43 | |||
| 5098b56655 | |||
| 47944d22d1 |
@@ -11,7 +11,9 @@ source groups with an independently selected main input. Sources can be host
|
||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||
Enabled explicit imports compose strictly within their group; precedence
|
||||
between groups combines mappings recursively. Both entry points return a complete,
|
||||
validated configuration with detached metadata and safe diagnostics.
|
||||
validated configuration with detached metadata and safe diagnostics. Successful
|
||||
results and load errors also provide passive `Source(id)` lookups alongside source
|
||||
enumeration.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
then report domain errors and warnings through read-only semantic validation. Secret
|
||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||
|
||||
@@ -46,21 +46,21 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{
|
||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||
DestinationIsFallback: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
|
||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionEvidence: fromEngineSourceVersionEvidence(value.VersionCheck),
|
||||
EmptyImportSelections: fromEngineEmptyImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||
}
|
||||
}
|
||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||
func fromEngineEmptyImportSelections(values []engine.ImportSelectionForFacade) []EmptyImportSelection {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ImportSelection, len(values))
|
||||
result := make([]EmptyImportSelection, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = ImportSelection{
|
||||
result[index] = EmptyImportSelection{
|
||||
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
||||
Selector: ImportSelector(value.Selector),
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -81,36 +81,19 @@ func toEngineOverrides(values []Override) []engine.Override {
|
||||
|
||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||
return engine.ImportDefaults{
|
||||
Required: cloneImportDefaultBool(value.Required),
|
||||
Follow: cloneImportDefaultBool(value.Follow),
|
||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
||||
Required: value.Required,
|
||||
Follow: value.FollowSymlinks,
|
||||
Hidden: value.IncludeHidden,
|
||||
Recursive: value.Recursive,
|
||||
Pattern: value.Pattern,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneImportDefaultBool(value *bool) *bool {
|
||||
func fromEngineSourceVersionEvidence(value *engine.SourceVersionCheck) *SourceVersionEvidence {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneImportDefaultStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &SourceVersionCheck{
|
||||
return &SourceVersionEvidence{
|
||||
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,
|
||||
|
||||
@@ -50,6 +50,21 @@ func (e *LoadError) Sources() []SourceInfo {
|
||||
return result
|
||||
}
|
||||
|
||||
// Source returns a detached record from the sanitized source evidence retained
|
||||
// before loading failed, including placement evidence available at that point.
|
||||
// IDs are local to the failed load. It does not acquire sources or access the
|
||||
// filesystem. Empty or unknown IDs return false.
|
||||
func (e *LoadError) Source(id string) (SourceInfo, bool) {
|
||||
if e == nil || e.value == nil {
|
||||
return SourceInfo{}, false
|
||||
}
|
||||
value, ok := e.value.Source(id)
|
||||
if !ok {
|
||||
return SourceInfo{}, false
|
||||
}
|
||||
return fromEngineSourceInfo(value), true
|
||||
}
|
||||
|
||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||
// control characters and propagates writer errors, including short writes.
|
||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||
|
||||
@@ -52,8 +52,8 @@ func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Searc
|
||||
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||
}
|
||||
|
||||
// Paths describes literal host-file candidates in the supplied order.
|
||||
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||
// SearchPaths describes literal host-file candidates in the supplied order.
|
||||
func SearchPaths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||
|
||||
// Searches concatenates descriptions in order and snapshots their entries.
|
||||
func Searches(searches ...Search) Search {
|
||||
|
||||
@@ -46,9 +46,9 @@ literal single filename components; it does not expand application-name
|
||||
placeholders. An application slug is required only when a selected directory or
|
||||
filename group uses it. Literal custom paths are not expanded for `~`, variables,
|
||||
or wildcards; relative paths are resolved from the working directory at load time.
|
||||
The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group
|
||||
contribute no candidates. An explicitly supplied empty path or filename is
|
||||
invalid. Slugs and custom filenames must be usable single host filename
|
||||
The zero `Search`, `Searches()`, `SearchPaths()`, and an empty custom filename
|
||||
group contribute no candidates. An explicitly supplied empty path or filename
|
||||
is invalid. Slugs and custom filenames must be usable single host filename
|
||||
components; ConfigKit preserves their case and spelling.
|
||||
|
||||
Search expansion is ordered concatenation. For each directory search, ConfigKit
|
||||
@@ -172,6 +172,18 @@ the returned configuration or inspection records cannot alter ConfigKit's privat
|
||||
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
|
||||
frozen redacted projection.
|
||||
|
||||
`Result.Source(id)` returns the detached record for an acquired source already
|
||||
listed by `Result.Sources()`. Source IDs are local to one load; empty and unknown
|
||||
IDs return false. This passive lookup uses retained evidence and does not reacquire
|
||||
the source, access the filesystem, or look up credentials. A source ID can be
|
||||
obtained from the source records or exact origin locations.
|
||||
|
||||
`LoadError.Source(id)` performs the same lookup over the partial, already-sanitized
|
||||
inventory returned by `LoadError.Sources()`. It preserves the source evidence and
|
||||
placement available when loading failed, which may be incomplete; it does not
|
||||
promise a record for every attempted source. Nil or zero-valued errors and empty
|
||||
or unknown IDs return false.
|
||||
|
||||
### Completion and resource lifetime
|
||||
|
||||
`Load` is synchronous and complete on return. Success means input assembly,
|
||||
@@ -278,7 +290,7 @@ the application root and are not moved beneath the main input's destination.
|
||||
`SourceInfo.Destination` reports each acquired source's effective mapping or
|
||||
sequence-container path, including imports. `PlacementMode` reports how that
|
||||
source was placed; mapping is the zero value, and CLI/overlay sources report
|
||||
mapping unless item placement was applied. `DestinationUnresolved` is true only
|
||||
mapping unless item placement was applied. `DestinationIsFallback` is true only
|
||||
when an enclosing item index needed to name the source's destination has not yet
|
||||
been assigned. In that case `Destination` reports the nearest resolved enclosing
|
||||
collection container and omits the unresolved local suffix. A source's own item
|
||||
@@ -445,10 +457,10 @@ successful result. Callback panics remain programming failures.
|
||||
|
||||
`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
|
||||
`SourceVersionModeMainOnly` (the zero value) 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.
|
||||
@@ -485,7 +497,7 @@ 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
|
||||
`SourceInfo.VersionEvidence` 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
|
||||
@@ -561,7 +573,7 @@ reports it absent. Queries return detached records.
|
||||
### Secret classification and effective rendering
|
||||
|
||||
Secret classification combines `configkit:"secret"` field tags with the optional
|
||||
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside
|
||||
pure `Definition.Sensitive(Path) bool` predicate. Tags on concrete structs inside
|
||||
supported dynamic values are retained in typed projections, including superseded
|
||||
defaults and pre-resolution values. A tagged or predicate-classified
|
||||
container makes all descendants sensitive. ConfigKit evaluates final and historical
|
||||
@@ -624,7 +636,7 @@ writes remain visible. When structural replay differs from the pre-CLI typed inp
|
||||
a `ChangeDecoding` event records retained defaults and canonical decoding. CLI
|
||||
contributions then retain their raw input representation while effective and
|
||||
resolution values use the typed projection. This built-in inspection operation is
|
||||
not accepted by `Resolution.Record`.
|
||||
not exposed as a manual recording method.
|
||||
|
||||
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||
including a superseded value that could not have decoded into the final Go field.
|
||||
@@ -659,11 +671,11 @@ unredacted mode.
|
||||
Retained history consists of immutable source applications and audited subtree
|
||||
deltas, rather than a full tree copy per field. Shared nodes count once toward a
|
||||
4,000,000-node retained-history limit; exceeding it fails loading with
|
||||
`explain.limit_exceeded`. Replaying one requested path is performed on demand, and
|
||||
`history.limit_exceeded`. Replaying one requested path is performed on demand, and
|
||||
the returned YAML strings and nested records cannot mutate later explanations.
|
||||
|
||||
Application commands should render the separate `SecretsReport` immediately after
|
||||
an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
|
||||
an explicit `LoadSecretsIntoEnvironment` bootstrap, then use `WriteDiagnostics`,
|
||||
`WriteEffective`, or `WriteExplanation` on the later load result as appropriate.
|
||||
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
||||
bundle these independent reports into a command framework.
|
||||
@@ -758,11 +770,12 @@ reference state without printing its value.
|
||||
|
||||
## Explicit secrets-directory bootstrap
|
||||
|
||||
`LoadSecretsDir(directory)` is a separate, opt-in startup operation. It is never
|
||||
called by `Load`, is not a YAML option, and does not participate in configuration
|
||||
validation. Applications choose the native directory path and should invoke the
|
||||
operation once before starting concurrent work. The selected path may itself be a
|
||||
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
||||
`LoadSecretsIntoEnvironment(directory)` is a separate, opt-in startup operation.
|
||||
It is never called by `Load`, is not a YAML option, and does not participate in
|
||||
configuration validation. Applications choose the native directory path and
|
||||
should invoke the operation once before starting concurrent work. The selected
|
||||
path may itself be a symlink; ConfigKit resolves it to an existing canonical
|
||||
directory before examining
|
||||
its immediate entries.
|
||||
|
||||
The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
|
||||
@@ -802,7 +815,7 @@ per-load `Resolution` context. The context's
|
||||
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||
null are never confused with absence or static defaults.
|
||||
|
||||
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
|
||||
`Resolution.EntryInfo(path)` returns detached effective metadata from the fixed
|
||||
post-decode entry state, including explicit CLI overrides, before application
|
||||
resolution runs. It includes retained static defaults, preserves explicit null, and
|
||||
uses the same exact semantic path matching as `Metadata.Lookup`; missing,
|
||||
@@ -825,18 +838,20 @@ exit; changing them cannot affect later queries, results, or another load.
|
||||
|
||||
`Default` assigns and records a derived value only when its path is not explicit.
|
||||
`Normalize` always assigns and records. For transformations performed directly by
|
||||
application code, `Resolution.Record` declares a derived-default or normalization
|
||||
operation; a recorded container covers changes to its descendants. Records require
|
||||
a schema-valid path, valid kind, safe nonempty reason, and schema-valid dependency
|
||||
paths. Repeated records at one path are coalesced: reasons and dependencies retain
|
||||
their first occurrence, the last helper value is authoritative, and any normalization
|
||||
makes the combined operation a normalization.
|
||||
application code, `Resolution.RecordDerivedDefault` and
|
||||
`Resolution.RecordNormalization` declare the operation already performed; a
|
||||
recorded container covers changes to its descendants. These methods do not assign
|
||||
values, and a derived-default record does not suppress work for explicit input.
|
||||
Records require a schema-valid path, safe nonempty reason, and schema-valid
|
||||
dependency paths. Repeated records at one path are coalesced: reasons and
|
||||
dependencies retain their first occurrence, the last helper value is authoritative,
|
||||
and any normalization makes the combined operation a normalization.
|
||||
|
||||
ConfigKit projects the configuration before and after the callback. Every net change
|
||||
must have an exact or ancestor record. Helper-assigned values must agree with the
|
||||
final value at their declared path, which supports ordinary map-entry copy/writeback
|
||||
while detecting forgotten writeback or unrelated targets. Invalid paths, kinds,
|
||||
values, records, helper targets, and unrecorded mutations fail the load. Equal-valued
|
||||
while detecting forgotten writeback or unrelated targets. Invalid paths, values,
|
||||
records, helper targets, and unrecorded mutations fail the load. Equal-valued
|
||||
derived assignments may still record application intent even when there is no
|
||||
structural change.
|
||||
|
||||
@@ -1214,6 +1229,8 @@ replaces rather than extends the default pattern. Nil `Pattern` leaves directory
|
||||
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
|
||||
a field applicable to selectors that do not support it, and load calls snapshot
|
||||
the supplied pointers and slice before source acquisition.
|
||||
The Go fields are named `FollowSymlinks` and `IncludeHidden`; the YAML keys
|
||||
remain `follow` and `hidden`.
|
||||
|
||||
A source's `configkit.sequences` declarations apply independently of import
|
||||
enablement; a `configkit.imports` declaration still requires `Options.Imports`.
|
||||
@@ -1249,15 +1266,15 @@ or glob selection fail with `import.missing`; it also makes missing named paths
|
||||
fail. Requiredness does not suppress invalid paths, containment failures,
|
||||
directories selected as files, permissions, parse errors, or composition errors.
|
||||
|
||||
For inspection, `SourceInfo.ImportSelections` records one outcome for each
|
||||
completed optional declaration whose entire selected set is empty. The record is
|
||||
attached to its declaring source, keeps the declaration path local to that source,
|
||||
identifies the declaration mapping (including its `SourceID`) and selector, and
|
||||
uses outcome `none`. Records remain in declaration order and are available
|
||||
through the existing result, load
|
||||
error, and callback source snapshots. They do not enumerate candidates, explain
|
||||
For inspection, `SourceInfo.EmptyImportSelections` records each completed optional
|
||||
declaration whose entire selected set is empty. The record is attached to its
|
||||
declaring source, keeps the declaration path local to that source, and identifies
|
||||
the declaration mapping (including its `SourceID`) and selector. Records remain
|
||||
in declaration order and are available
|
||||
through the existing result, load-error, and callback source snapshots. They do
|
||||
not enumerate candidates, explain
|
||||
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
|
||||
regardless of requiredness: it produces a `none` record when effectively optional,
|
||||
regardless of requiredness: it produces a record when effectively optional,
|
||||
and no record when effectively required. Snapshots detach the record paths and
|
||||
slices.
|
||||
|
||||
|
||||
@@ -99,15 +99,20 @@ records why discovery is explicitly selected.
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||
is resolved independently: a declaration's explicit value wins, then the
|
||||
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
||||
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
||||
application default, then the library default. `Required`, `FollowSymlinks`,
|
||||
`IncludeHidden`, and `Recursive` use nil pointers for unspecified values.
|
||||
`Pattern` applies to
|
||||
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||
slice selects no files. A declaration's own `pattern` replaces the application
|
||||
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||
The Go fields `FollowSymlinks` and `IncludeHidden` correspond to the unchanged
|
||||
YAML keys `follow` and `hidden`.
|
||||
|
||||
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||
declaring source's `ImportSelections`. These records identify the source-local
|
||||
declaration path, its authored location and selector, and the `none` outcome;
|
||||
declaring source's `EmptyImportSelections`. `Result.Source(id)` and
|
||||
`LoadError.Source(id)` retrieve one retained record directly when its load-local
|
||||
ID is known. These records identify the source-local declaration path, its
|
||||
authored location and selector;
|
||||
they do not include candidate names or a guessed reason for absence. Returned
|
||||
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||
owns selector applicability and exact absence semantics.
|
||||
@@ -117,7 +122,7 @@ owns selector applicability and exact absence semantics.
|
||||
[`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
|
||||
`SourceInfo.VersionEvidence` 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,
|
||||
@@ -264,7 +269,7 @@ conversion API.
|
||||
## Source-aware application path policy
|
||||
|
||||
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
|
||||
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
|
||||
path-bearing example. Its application resolver uses `Resolution.EntryInfo` and
|
||||
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
|
||||
supplied invocation directory, retained defaults use the canonical main source,
|
||||
and a file-authored scalar uses its one declaring source. Absolute paths remain
|
||||
@@ -301,10 +306,47 @@ The examples preserve domain meaning while adopting one configuration convention
|
||||
parsers translate only explicitly set configuration flags into `Override` records.
|
||||
Separate invocation state remains outside configuration.
|
||||
- Credential fields migrate to environment-name `Secret` references. Applications
|
||||
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap;
|
||||
explicitly call `LoadSecretsIntoEnvironment` before `Load` when they choose
|
||||
file bootstrap;
|
||||
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
||||
process environment.
|
||||
|
||||
### Pre-v1 public API cleanup
|
||||
|
||||
The pre-v1 API cleanup replaces these names outright; it does not retain
|
||||
compatibility aliases:
|
||||
|
||||
| Previous API | Current API |
|
||||
| --- | --- |
|
||||
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
|
||||
| `Definition.Secret` | `Definition.Sensitive` |
|
||||
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
|
||||
| `SourceVersionCheck` | `SourceVersionEvidence` |
|
||||
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
|
||||
| `ImportSelection` | `EmptyImportSelection` |
|
||||
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
|
||||
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
|
||||
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
|
||||
| `Paths(paths...)` | `SearchPaths(paths...)` |
|
||||
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
|
||||
| `LayerBase` | `LayerMain` |
|
||||
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
|
||||
| `CodeResolveInvalidKind` / `CodeOptionsUnavailable` | Removed; no replacement |
|
||||
| `ImportSelection.Outcome`, `ImportSelectionOutcome`, `ImportSelectionNone` | Removed; empty-selection records no longer carry an outcome |
|
||||
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
|
||||
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
|
||||
| Enumerating `Result.Sources()` / `LoadError.Sources()` to find one ID | `Result.Source(id)` / `LoadError.Source(id)` |
|
||||
| Provenance string `"base"` | `"main"` |
|
||||
| History diagnostic string `"explain.limit_exceeded"` | `"history.limit_exceeded"` |
|
||||
|
||||
The YAML import keys `follow` and `hidden`, the `configkit:"secret"` tag, and
|
||||
typed `Secret` references are unchanged. Consumers that compare literal strings
|
||||
or keep serialized diagnostic/provenance expectations may need to update those
|
||||
alongside Go references. This repository guidance does not claim that downstream
|
||||
applications have migrated and does not select a release version for this
|
||||
breaking pre-v1 change. `ChangeKind` and its inspection constants remain; use
|
||||
`Default` and `Normalize` when the helper should both assign and record a value.
|
||||
|
||||
### Selected-overlay migration
|
||||
|
||||
To adopt the current selected-overlay API, rename `Options.Profile`,
|
||||
@@ -330,7 +372,7 @@ focused behavioral owner:
|
||||
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
||||
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| `LoadSecretsIntoEnvironment`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
|
||||
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
|
||||
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
|
||||
|
||||
@@ -113,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||
```
|
||||
|
||||
Remaining optimization candidates belong to
|
||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
||||
[future performance experiments](roadmap/future.md#5-performance-experiments).
|
||||
|
||||
The completed effective-configuration diff roadmap, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `1c2610c` under
|
||||
@@ -199,6 +199,32 @@ git show 34cbc0d:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||
The completed audit-remediation, internal-boundary simplification, shared-history,
|
||||
and public API cleanup roadmaps, reviews, and resolved findings are preserved at
|
||||
`68400ff` under `docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 68400ff:docs/roadmap/api.md
|
||||
git show 68400ff:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Their earlier completed implementation plans are preserved at
|
||||
`bb5540b:docs/roadmap/implementation.md` (audit remediation),
|
||||
`f6d54a0:docs/roadmap/implementation.md` (internal-boundary simplification), and
|
||||
`07e4ae5:docs/roadmap/implementation.md` (shared history). The plan at
|
||||
`68400ff:docs/roadmap/implementation.md` records the public API cleanup.
|
||||
Current behavior and migration information belong to the
|
||||
[configuration contract](config.md), public declaration comments, and
|
||||
[consumer guide](consumers/README.md#pre-v1-public-api-cleanup).
|
||||
|
||||
The consolidated audit, including
|
||||
findings, evidence, and settled scope decisions, is preserved at
|
||||
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
|
||||
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
|
||||
|
||||
```sh
|
||||
git show 453da68:docs/roadmap/audit.md
|
||||
```
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
Acquire declared dependencies explicitly before entering an offline
|
||||
|
||||
@@ -33,6 +33,26 @@ sandbox guarantee. `source_context.go` derives passive resource contexts from
|
||||
resolved sources for later caller-owned reads; those records do not retain live
|
||||
acquisition handles.
|
||||
|
||||
Host and native link bindings share one iterative link-frame evaluator. It
|
||||
tracks active links for cycle detection and reuses successfully completed link
|
||||
targets while resolving the remaining path components; each binding retains its
|
||||
own namespace and target-containment rules. Directory reads distinguish an
|
||||
initial-open failure from failures after opening: only an initial missing
|
||||
directory can be treated as optional, while enumeration and close failures
|
||||
remain selection errors, including errors from the standard `fs.ReadDir`
|
||||
fallback.
|
||||
|
||||
Glob selection keeps traversal coordination in `selectGlobImportCandidates`:
|
||||
it advances literal and patterned segments, reads directories, unions and sorts
|
||||
candidates, validates final targets, then applies exclusions and requiredness.
|
||||
For each patterned directory entry, `prepareGlobEntry` handles matching and
|
||||
hidden filtering, containment, target and link inspection, intermediate-directory
|
||||
eligibility, host/native access-path selection, and candidate identity and label.
|
||||
It returns a candidate, an ordinary skip, or a diagnostic without mutating the
|
||||
selector's candidate set or traversal state. Directory entry preparation remains
|
||||
separate because its matching, absence, recursion, and traversal-state rules
|
||||
differ.
|
||||
|
||||
Acquisition remains authored: declarations are visited in source order and each
|
||||
selected candidate set in namespace-path lexical order. This preserves source
|
||||
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||
|
||||
@@ -20,14 +20,19 @@ Implemented component owners:
|
||||
inheritance and decoding, before application resolution. The engine also owns
|
||||
shared source-version checking with sanitized declaration evidence.
|
||||
Explicit import discovery supports declared paths, directory searches, and
|
||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
||||
root facade without changing acquisition order. Import and inheritance
|
||||
resolution share dependency-ready scheduling within their composition scopes.
|
||||
globs; root-owned defaults are converted to the engine's single defaults input
|
||||
and snapshotted during load preflight before acquisition, while source-local
|
||||
empty-selection evidence crosses the facade without changing acquisition
|
||||
order. The root constructs placement directly on engine options and inputs
|
||||
using explicit enum conversion; public types and fields remain root-owned.
|
||||
Import and inheritance resolution share dependency-ready scheduling within
|
||||
their composition scopes.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
provenance/history, passive source-context retention, secret resolution and
|
||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
||||
bootstrap, operation-local secret failure-location attribution, safe
|
||||
inspection rendering, and effective-value comparison over
|
||||
frozen inspection snapshots. It never imports the root package.
|
||||
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
||||
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
||||
@@ -90,8 +95,14 @@ bounded algorithms.
|
||||
|
||||
## Implementation flow
|
||||
|
||||
The root facade converts caller-owned values into engine-owned requests. The
|
||||
ordinary and grouped entry points share a normalized `loadRequest`: see
|
||||
The root facade converts caller-owned values into engine-owned requests. Its
|
||||
`ImportDefaults` conversion maps fields without taking ownership; engine load
|
||||
preflight snapshots the defaults once before source acquisition or callbacks.
|
||||
Ordinary and grouped loading then carry that read-only value through their
|
||||
normalized `loadRequest` and acquisition policy, while declaration resolution
|
||||
copies values needed by its independently owned effective policy. Placement is
|
||||
constructed directly on the engine's options and input records after explicit
|
||||
root-to-engine enum conversion. See
|
||||
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
|
||||
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
||||
version, default-import, and override policy stays distinct from each input's
|
||||
@@ -101,7 +112,10 @@ source, filesystem, destination, and import settings.
|
||||
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
||||
input is acquired under that policy; grouped requests retain acquired underlay
|
||||
scopes until their tier is composed in
|
||||
[`load_request.go`](../../internal/engine/load_request.go). Live
|
||||
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
|
||||
share one assembly handoff that applies collection declarations and projects
|
||||
only that tier's source/version evidence; the caller keeps main-version
|
||||
protection and its projection explicit. Live
|
||||
filesystem access handles are confined to acquisition and closed there. Resolved
|
||||
filesystem facts used to authorize access are separate from passively retained
|
||||
resource contexts. Non-import validation retains those facts on `assembledInput`
|
||||
@@ -137,6 +151,27 @@ descriptor caching and type policy, and
|
||||
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
||||
those descriptors for typed decoding.
|
||||
|
||||
History and inspection use one private evidence owner. The
|
||||
[`history_model.go`](../../internal/engine/history_model.go) builder receives
|
||||
baseline, source-composition, inheritance, typed-checkpoint, and CLI evidence
|
||||
in [`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go),
|
||||
before application resolution begins. The callback retains its separate fixed
|
||||
entry view; it does not read a builder that continues to change. After successful
|
||||
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
||||
adds the original resolution records and their net replay groups. At the existing
|
||||
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
|
||||
freezes that builder and counts the retained document roots before secret lookup
|
||||
and validation.
|
||||
|
||||
The frozen model is shared by `Metadata` and `Inspection`. Its shape-aware
|
||||
selection in [`history_select.go`](../../internal/engine/history_select.go)
|
||||
drives metadata history, explanation, and historical-path membership; the model
|
||||
also supplies historical diagnostic locations and sensitivity/dependency
|
||||
evidence. `Inspection` replays selected payloads to produce detached, safely
|
||||
rendered values, while metadata queries return detached attribution records.
|
||||
History and explanation behavior is covered by focused `history_*_test.go`
|
||||
tests alongside metadata, secret, inheritance, and facade integration tests.
|
||||
|
||||
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
|
||||
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
||||
discovery, and destination tests; inheritance by the inheritance test files;
|
||||
|
||||
@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
||||
"$releasekit_bin" version
|
||||
```
|
||||
|
||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
||||
The module-selected executable is ReleaseKit v0.4.2. Use the built executable
|
||||
for publication to preserve its exit status and publication-state diagnostics.
|
||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||
|
||||
70
docs/releases/v0.10.2.md
Normal file
70
docs/releases/v0.10.2.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ConfigKit v0.10.2
|
||||
|
||||
This patch release fixes edge-case loading failures, resource cleanup, and limit
|
||||
accounting, and simplifies ConfigKit's internal loading machinery. Public APIs
|
||||
and documented configuration semantics remain unchanged from v0.10.1.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Terminate pointer-only recursive type traversal safely during decoding and
|
||||
schema-path resolution, preserving nullable values and supported recursive
|
||||
structures. Roll back failed dynamic descriptor construction so subsequent
|
||||
operations do not reuse incomplete state or panic.
|
||||
- Accept finite, non-cyclic slice views that share backing storage while
|
||||
retaining cycle detection and detached output.
|
||||
- Deactivate resolution, validation, and version-check contexts when callbacks
|
||||
panic, and close library-owned import roots during panic unwinding. The
|
||||
original application panic continues to propagate.
|
||||
- Report directory enumeration and close failures instead of treating them as
|
||||
optional absence, including failures reported as not-exist after a successful
|
||||
open. Genuinely missing optional directories retain their existing behavior.
|
||||
- Reuse completed symlink dependencies during host and native import discovery,
|
||||
avoiding exponential repeated expansion of finite link graphs while retaining
|
||||
cycle detection and containment checks.
|
||||
- Check existing projection budgets before bulk container allocation and account
|
||||
for the complete retained inspection-history graph, including replay and
|
||||
separately copied inheritance evidence.
|
||||
- Apply the documented append/upsert sequence requirements to non-sequence
|
||||
contributions at retained collection-policy destinations.
|
||||
|
||||
## Internal improvements
|
||||
|
||||
- Index collection-policy retirement and prepare sparse incoming policy changes
|
||||
instead of repeatedly scanning or copying unrelated retained policy.
|
||||
- Reuse successful dynamic-type eligibility checks within an operation and index
|
||||
tier-history lookups, compound identity restoration, and secret-failure source
|
||||
attribution to avoid repeated scans.
|
||||
- Remove unused snapshots, evidence, helpers, fields, and test-only production
|
||||
paths. Clarify acquisition handoffs and separate collection preparation,
|
||||
application, and finalization while sharing sequence-history construction.
|
||||
- Separate patterned glob entry preparation from traversal. Consolidate engine
|
||||
import defaults, snapshot them during load preflight, and construct placement
|
||||
directly across the facade while keeping public types root-owned.
|
||||
- Add regression coverage for the corrected failures, public historical paths,
|
||||
defaults snapshots, and resource ownership. Strengthen fuzz detachment checks
|
||||
and replace redundant or implementation-coupled assertions.
|
||||
- Update release tooling from ReleaseKit v0.4.1 to v0.4.2.
|
||||
|
||||
These changes remove redundant work and improve scaling in the identified paths;
|
||||
no end-to-end performance improvement has been quantified.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
No consumer API or configuration-format migration is required. The
|
||||
[configuration contract](../config.md) remains authoritative.
|
||||
|
||||
Loads that previously succeeded despite directory acquisition failures, invalid
|
||||
append/upsert contributions, or incomplete retained-history accounting may now
|
||||
return the documented structured errors. These corrections enforce existing
|
||||
requirements and limits rather than introducing new ones.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.2
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency and platform support
|
||||
are unchanged. Publication follows the existing [release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/docs/oss/attribution.md)
|
||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||
validation toolchain. Retire completed planning and measurement records into
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Potential Future Features
|
||||
# Potential Future Work
|
||||
|
||||
This document lists potential enhancements for consideration in future ConfigKit
|
||||
releases. Entries are not accepted contracts, implementation plans, or release
|
||||
commitments. Adopting a candidate requires an explicit design decision and a
|
||||
separate plan. Significant architectural exclusions and their rationale belong
|
||||
This document lists potential enhancements and maintenance work for consideration
|
||||
in future ConfigKit releases. Entries are not accepted contracts, implementation
|
||||
plans, or release commitments. Adopting a candidate requires an explicit design
|
||||
decision and a separate plan. Significant architectural exclusions and their rationale belong
|
||||
in ADRs; this document does not maintain a list of rejected proposals.
|
||||
|
||||
## 1. Recursive body merging for identified records
|
||||
## 3. Recursive body merging for identified records
|
||||
|
||||
Status: deferred. The implemented
|
||||
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
||||
@@ -20,7 +20,7 @@ presence and ordering, and how provenance, history, sensitivity, and resource
|
||||
context follow partially retained records. Any change requires a new decision and
|
||||
implementation plan; this entry does not alter current upsert behavior.
|
||||
|
||||
## 2. Required version declarations for selected sources
|
||||
## 4. Required version declarations for selected sources
|
||||
|
||||
Status: deferred. PromptKit requested a way to require version declarations on
|
||||
a selected subset of source files while permitting other fragments to omit them.
|
||||
@@ -56,7 +56,7 @@ compatible version or replace final configuration validation.
|
||||
behavior for absent optional inputs and preserve source-local diagnostics,
|
||||
inspection evidence, and existing sensitivity handling.
|
||||
|
||||
## 3. Performance experiments
|
||||
## 5. Performance experiments
|
||||
|
||||
Status: deferred pending evidence. The completed performance audit identified
|
||||
four internal optimization candidates for separate investigation:
|
||||
@@ -83,3 +83,22 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
|
||||
across loads without introducing aliasing?
|
||||
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
||||
without excessive retained memory?
|
||||
|
||||
## 6. Public path comparison
|
||||
|
||||
Status: deferred pending a concrete consumer need. This preserves A2 from the
|
||||
completed API review at `68400ff:docs/roadmap/review.md`.
|
||||
|
||||
`Path.String()` is a JSON Pointer spelling, not a complete semantic identity:
|
||||
an explicit numeric mapping key and a sequence index can share it. The public
|
||||
type is opaque and currently non-comparable.
|
||||
|
||||
If consumers need comparison, consider a small exact-representation operation
|
||||
rather than exposing mutable segments or introducing a pattern language. A
|
||||
proposal must distinguish exact segment representation, including parsed versus
|
||||
explicit segments and invalid state, from resolution against a document. Pointer
|
||||
string equality must not be presented as semantic equality.
|
||||
|
||||
Keeping `Path` solely as an opaque query/building value remains an alternative.
|
||||
No current contract changes or implementation commitment follow from this entry;
|
||||
a comparison operation can be added compatibly later.
|
||||
|
||||
@@ -186,7 +186,8 @@ func ExampleOverride() {
|
||||
},
|
||||
Overrides: overrides,
|
||||
})
|
||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||
source, _ := result.Source("source-1")
|
||||
fmt.Println(result.Config.Workers, source.Label, err)
|
||||
// Output: 0 --workers <nil>
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Overri
|
||||
resolveNotariusReference(configuration, resolution, invocationDir)
|
||||
return nil
|
||||
},
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
||||
Sensitive: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func resolveNotariusReference(configuration *NotariusPathConfig, resolution *con
|
||||
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
|
||||
return
|
||||
}
|
||||
info, found := resolution.InputInfo(path)
|
||||
info, found := resolution.EntryInfo(path)
|
||||
if !found {
|
||||
resolution.Warning(path, "relative reference has no entry provenance")
|
||||
return
|
||||
|
||||
@@ -82,7 +82,7 @@ func Example_weatherStartup() {
|
||||
defer os.RemoveAll(directory)
|
||||
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
|
||||
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
|
||||
report, bootstrapErr := configkit.LoadSecretsDir(directory)
|
||||
report, bootstrapErr := configkit.LoadSecretsIntoEnvironment(directory)
|
||||
var reportOutput bytes.Buffer
|
||||
_, reportWriteErr := report.WriteTo(&reportOutput)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
|
||||
continue
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := resolution.InputInfo(contentPath)
|
||||
info, found := resolution.EntryInfo(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("prompt resource has no single declaring source")
|
||||
}
|
||||
@@ -104,9 +104,9 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
|
||||
}
|
||||
prompt.ResourceText = string(data)
|
||||
collection.Prompts[promptID] = prompt
|
||||
resolution.Record(
|
||||
resolution.RecordNormalization(
|
||||
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
||||
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
|
||||
"read source-relative prompt content", contentPath,
|
||||
)
|
||||
}
|
||||
configuration.Collections[collectionID] = collection
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
||||
}
|
||||
|
||||
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
||||
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
|
||||
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerMain || len(versionInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
||||
}
|
||||
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
||||
|
||||
@@ -101,8 +101,8 @@ func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resoluti
|
||||
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
||||
if endpoint.URL != url {
|
||||
endpoint.URL = url
|
||||
resolution.Record(configkit.Keys("service", "endpoint", "url"),
|
||||
configkit.ChangeNormalization, "derived endpoint URL",
|
||||
resolution.RecordNormalization(configkit.Keys("service", "endpoint", "url"),
|
||||
"derived endpoint URL",
|
||||
configkit.Keys("service", "endpoint", "host"),
|
||||
configkit.Keys("service", "endpoint", "port"))
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func Example_workingDirectoryPreset() {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
|
||||
source, _ := result.Source("source-1")
|
||||
fmt.Println(result.Config.Name, filepath.Base(source.Path))
|
||||
// Output: example service.yml
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func Example_rootRelativeImports() {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
source := result.Sources()[2]
|
||||
source, _ := result.Source("source-3")
|
||||
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>
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func Example_sourceVersionAdoption() {
|
||||
}
|
||||
checks, redacted := 0, true
|
||||
for _, source := range result.Sources() {
|
||||
if check := source.VersionCheck; check != nil {
|
||||
if check := source.VersionEvidence; check != nil {
|
||||
checks++
|
||||
redacted = redacted && check.Redacted && check.Version.Text == ""
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
|
||||
t.Fatalf("versioned service sources = %#v", sources)
|
||||
}
|
||||
for _, source := range sources {
|
||||
check := source.VersionCheck
|
||||
check := source.VersionEvidence
|
||||
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
||||
!check.Redacted || check.Version.Text != "" {
|
||||
t.Fatalf("safe source version evidence = %#v", sources)
|
||||
@@ -119,7 +119,7 @@ func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
||||
}
|
||||
for _, key := range []string{"enabled", "workers"} {
|
||||
info, found := result.Metadata().Lookup(configkit.Keys(key))
|
||||
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase {
|
||||
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerMain {
|
||||
t.Fatalf("%s presence = %#v, %v", key, info, found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ func Example_identifiedPromptCatalog() {
|
||||
}
|
||||
emptySearches := 0
|
||||
for _, source := range result.Sources() {
|
||||
for _, selection := range source.ImportSelections {
|
||||
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
||||
for _, selection := range source.EmptyImportSelections {
|
||||
if selection.Selector == configkit.ImportSelectorDirectory {
|
||||
emptySearches++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
|
||||
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
||||
configkit.Paths(absoluteMain),
|
||||
configkit.SearchPaths(absoluteMain),
|
||||
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
||||
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
||||
}),
|
||||
@@ -54,7 +54,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
|
||||
},
|
||||
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
||||
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
||||
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization,
|
||||
resolution.RecordNormalization(configkit.Keys("resolved_label"),
|
||||
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -47,7 +47,7 @@ func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configk
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
Sensitive: func(path configkit.Path) bool {
|
||||
return path.String() == "/service/version"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := configkit.LoadSecretsDir(directory)
|
||||
report, err := configkit.LoadSecretsIntoEnvironment(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
|
||||
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
|
||||
}
|
||||
|
||||
failedReport, err := configkit.LoadSecretsDir("")
|
||||
failedReport, err := configkit.LoadSecretsIntoEnvironment("")
|
||||
var secretsError *configkit.SecretsError
|
||||
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
|
||||
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
|
||||
@@ -203,7 +203,7 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
|
||||
if !resolution.IsExplicit(configkit.Keys("input")) {
|
||||
resolution.Error(configkit.Keys("input"), "input is required")
|
||||
}
|
||||
input, ok := resolution.InputInfo(configkit.Keys("input"))
|
||||
input, ok := resolution.EntryInfo(configkit.Keys("input"))
|
||||
if !ok || !input.Explicit {
|
||||
resolution.Error(configkit.Keys("input"), "missing input provenance")
|
||||
}
|
||||
@@ -245,6 +245,8 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
|
||||
}
|
||||
for _, context := range []func(){
|
||||
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
|
||||
func() { retainedResolution.RecordDerivedDefault(configkit.Keys("input"), "too late") },
|
||||
func() { retainedResolution.RecordNormalization(configkit.Keys("input"), "too late") },
|
||||
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
|
||||
} {
|
||||
func() {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
|
||||
func TestFacadeCompatibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
for _, candidate := range []struct {
|
||||
name string
|
||||
type_ reflect.Type
|
||||
}{
|
||||
{"Path", reflect.TypeFor[configkit.Path]()},
|
||||
{"Source", reflect.TypeFor[configkit.Source]()},
|
||||
{"Secret", reflect.TypeFor[configkit.Secret]()},
|
||||
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
|
||||
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
|
||||
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
|
||||
} {
|
||||
if candidate.type_.PkgPath() != packagePath {
|
||||
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
|
||||
}
|
||||
}
|
||||
|
||||
constructed := configkit.Options[facadeCompatibilityConfig]{
|
||||
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
||||
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
||||
@@ -56,11 +38,6 @@ func TestFacadeCompatibility(t *testing.T) {
|
||||
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
||||
t.Fatalf("constructed override path = %q, want /value", got)
|
||||
}
|
||||
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
|
||||
if publicResult.Config.Value != "constructed" {
|
||||
t.Fatal("constructed result lost its public configuration")
|
||||
}
|
||||
|
||||
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
||||
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
||||
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
||||
|
||||
41
facade_container_origin_test.go
Normal file
41
facade_container_origin_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestOverlayExplanationUsesQueryScopedOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := configkit.NewFilesystem("origin", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("a: base\ngroup: {x: X, y: Y}\n")},
|
||||
"overlays/dev.yml": {Data: []byte("a: overlay\ngroup: {x: O}\n")},
|
||||
})
|
||||
result, err := configkit.Load[map[string]any](filesystem.File("main.yml"), configkit.Options[map[string]any]{Overlay: "dev"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path configkit.Path
|
||||
line, column int
|
||||
}{
|
||||
{configkit.Keys(), 1, 1},
|
||||
{configkit.Keys("group"), 2, 8},
|
||||
{configkit.Keys("group", "x"), 2, 12},
|
||||
} {
|
||||
explanation, found := result.Explain(test.path)
|
||||
if !found || len(explanation.History) == 0 {
|
||||
t.Fatalf("missing explanation for %s", test.path)
|
||||
}
|
||||
origin := explanation.History[len(explanation.History)-1].Origin
|
||||
if origin.Layer != configkit.LayerOverlay || len(origin.Locations) != 1 {
|
||||
t.Fatalf("overlay origin for %s = %#v", test.path, origin)
|
||||
}
|
||||
location := origin.Locations[0]
|
||||
if location.SourceID != "source-2" || location.Line != test.line || location.Column != test.column {
|
||||
t.Fatalf("overlay location for %s = %#v; want line %d column %d", test.path, location, test.line, test.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,11 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
||||
call func()
|
||||
}{
|
||||
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
||||
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
||||
{"EntryInfo", func() { state.resolution.EntryInfo(path) }},
|
||||
{"Source", func() { state.resolution.Source("source") }},
|
||||
{"SourceContext", func() { state.resolution.SourceContext("source") }},
|
||||
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
||||
{"RecordDerivedDefault", func() { state.resolution.RecordDerivedDefault(path, "reason") }},
|
||||
{"RecordNormalization", func() { state.resolution.RecordNormalization(path, "reason") }},
|
||||
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
||||
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
||||
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||
callbacks.resolve++
|
||||
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
||||
input, ok := resolution.InputInfo(resourcePath)
|
||||
input, ok := resolution.EntryInfo(resourcePath)
|
||||
if !ok || len(input.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("mounted resource input has no single source origin")
|
||||
}
|
||||
@@ -370,11 +370,11 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||
}
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
||||
entry["resource_text"] = string(data)
|
||||
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
|
||||
resolution.RecordNormalization(configkit.Keys("catalog", "entries", "item", "resource_text"), "read source-relative summary", resourcePath)
|
||||
return nil
|
||||
},
|
||||
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||
Sensitive: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -405,7 +405,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||
if err != nil || string(data) != "source-relative summary" {
|
||||
t.Fatalf("completed relative resource = %q, %v", data, err)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
|
||||
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerMain {
|
||||
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
||||
}
|
||||
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
||||
|
||||
95
facade_diagnostic_order_test.go
Normal file
95
facade_diagnostic_order_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeCompositionConflictKeepsClaimantOrder(t *testing.T) {
|
||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"a.yml": &fstest.MapFile{Data: []byte(`configkit:
|
||||
imports:
|
||||
- path: z.yml
|
||||
`)},
|
||||
"b.yml": &fstest.MapFile{Data: []byte("x: b\n")},
|
||||
"c.yml": &fstest.MapFile{Data: []byte("x: c\n")},
|
||||
"z.yml": &fstest.MapFile{Data: []byte("x: z\n")},
|
||||
})
|
||||
main := configkit.Mapping("main", map[string]any{
|
||||
"configkit": map[string]any{"imports": []any{
|
||||
map[string]any{"path": "a.yml"},
|
||||
map[string]any{"path": "b.yml"},
|
||||
map[string]any{"path": "c.yml"},
|
||||
}},
|
||||
})
|
||||
_, err := configkit.Load[map[string]any](main, configkit.Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &configkit.FilesystemContext{
|
||||
FS: &filesystem, Root: ".", BaseDir: ".",
|
||||
},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("Load error = %v, want composition LoadError", err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v, want one composition conflict", diagnostics)
|
||||
}
|
||||
diagnostic := diagnostics[0]
|
||||
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != "/x" || diagnostic.Primary == nil {
|
||||
t.Fatalf("composition diagnostic = %#v", diagnostic)
|
||||
}
|
||||
|
||||
pathsByID := make(map[string]string)
|
||||
var acquisitionOrder []string
|
||||
for _, source := range loadError.Sources() {
|
||||
pathsByID[source.ID] = source.Path
|
||||
for _, name := range []string{"z.yml", "b.yml", "c.yml"} {
|
||||
if source.Path == name || strings.HasSuffix(source.Path, "/"+name) {
|
||||
acquisitionOrder = append(acquisitionOrder, strings.TrimSuffix(name, ".yml"))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(acquisitionOrder, ",") != "z,b,c" {
|
||||
t.Fatalf("claimant acquisition order = %v, want z,b,c", acquisitionOrder)
|
||||
}
|
||||
claimant := func(location configkit.Location) string {
|
||||
for _, name := range []string{"b.yml", "c.yml", "z.yml"} {
|
||||
if pathsByID[location.SourceID] == name || strings.HasSuffix(pathsByID[location.SourceID], "/"+name) {
|
||||
return strings.TrimSuffix(name, ".yml")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if got := claimant(*diagnostic.Primary); got != "b" {
|
||||
t.Fatalf("primary claimant = %q, want b (sources: %#v)", got, pathsByID)
|
||||
}
|
||||
if len(diagnostic.Related) != 2 || claimant(diagnostic.Related[0]) != "c" || claimant(diagnostic.Related[1]) != "z" {
|
||||
t.Fatalf("related claimants = %#v, want c then z", diagnostic.Related)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := configkit.WriteDiagnostics(&output, diagnostics); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
positions := []int{
|
||||
strings.Index(output.String(), diagnostic.Primary.SourceID),
|
||||
strings.Index(output.String(), diagnostic.Related[0].SourceID),
|
||||
strings.Index(output.String(), diagnostic.Related[1].SourceID),
|
||||
}
|
||||
if positions[0] < 0 || positions[1] <= positions[0] || positions[2] <= positions[1] {
|
||||
t.Fatalf("rendered claimant order = %v in %q", positions, output.String())
|
||||
}
|
||||
|
||||
diagnostics[0].Related[0].SourceID = "mutated"
|
||||
if got := claimant(loadError.Diagnostics()[0].Related[0]); got != "c" {
|
||||
t.Fatalf("LoadError.Diagnostics retained caller mutation; first claimant = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,10 @@ func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testin
|
||||
t.Helper()
|
||||
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
|
||||
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
||||
Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Items[0] = "public scalar"
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
|
||||
resolution.RecordNormalization(configkit.Keys("items"), "normalize first item")
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
@@ -71,10 +71,10 @@ func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
|
||||
Dynamic map[string]any `yaml:"dynamic"`
|
||||
}
|
||||
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
||||
Sensitive: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
delete(current.Dynamic, "entry")
|
||||
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
|
||||
resolution.RecordNormalization(configkit.Keys("dynamic", "entry"), "remove entry")
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
@@ -221,7 +221,7 @@ func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
|
||||
resolve := func(value string) configkit.Options[config] {
|
||||
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
|
||||
current.Count = len(value) + 8
|
||||
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count")
|
||||
resolution.RecordNormalization(configkit.Keys("count"), "set effective count")
|
||||
return nil
|
||||
}}}
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
|
||||
|
||||
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
|
||||
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
|
||||
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool {
|
||||
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Sensitive: func(path configkit.Path) bool {
|
||||
return path.String() == "/secret"
|
||||
}}}
|
||||
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
|
||||
@@ -93,14 +93,14 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
||||
names := []string{"config.yml"}
|
||||
groups := []FilenameGroup{CustomFilenames(names...)}
|
||||
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
||||
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
|
||||
search := Searches(SearchPaths(paths...), SearchDirectory(root, directoryOptions...), SearchPaths(laterPath))
|
||||
source := SearchConfig("", search, SearchOptions{Required: true})
|
||||
|
||||
paths[0] = laterPath
|
||||
names[0] = "later.yml"
|
||||
groups[0] = CustomFilenames("later.yml")
|
||||
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
||||
search = Paths(laterPath)
|
||||
search = SearchPaths(laterPath)
|
||||
|
||||
result, err := Load[facadeDiscoveryConfig](source)
|
||||
if err != nil || result.Config.Value != "selected" {
|
||||
@@ -110,7 +110,7 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
||||
t.Fatalf("ordered search sources = %#v", sources)
|
||||
}
|
||||
|
||||
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||
_, err = Load[facadeDiscoveryConfig](SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
||||
@@ -131,7 +131,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
||||
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
||||
result, err := LoadLayered(
|
||||
Input{Source: SearchConfig("", Paths(mainPath))},
|
||||
Input{Source: SearchConfig("", SearchPaths(mainPath))},
|
||||
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
||||
@@ -142,7 +142,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
||||
t.Fatalf("layered search = %#v, %v", result, err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
|
||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerMain ||
|
||||
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
||||
t.Fatalf("layered search sources = %#v", sources)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
||||
filesystem := &FilesystemContext{Root: root}
|
||||
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
||||
|
||||
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
|
||||
selected, err := Load(SearchConfig("", SearchPaths(mainPath)), options)
|
||||
if err != nil || selected.Config.Value != "overlay" {
|
||||
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
||||
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
||||
}
|
||||
|
||||
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
|
||||
missing := SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")))
|
||||
searchedAbsent, err := Load(missing, options)
|
||||
if err != nil {
|
||||
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
||||
@@ -201,7 +201,7 @@ func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
||||
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
||||
outsideRoot := t.TempDir()
|
||||
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
||||
search := SearchConfig("", Paths(outside, inside))
|
||||
search := SearchConfig("", SearchPaths(outside, inside))
|
||||
|
||||
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
||||
var loadErr *LoadError
|
||||
@@ -228,7 +228,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
|
||||
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
||||
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||
result, err := Load(SearchConfig("", SearchPaths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
||||
Defaults: func() facadeDiscoveryLifecycleConfig {
|
||||
defaultsCalls++
|
||||
@@ -237,7 +237,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
|
||||
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
||||
resolveCalls++
|
||||
configuration.Resolved = "from:" + configuration.Value
|
||||
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
|
||||
resolution.RecordNormalization(Keys("resolved"), "derive from selected value", Keys("value"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
||||
@@ -272,7 +272,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
||||
Destination: Keys("items"),
|
||||
PlacementMode: PlacementModeSequenceItem,
|
||||
}
|
||||
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
|
||||
selected, err := Load(SearchConfig("", SearchPaths(selectedPath)), options)
|
||||
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
||||
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
||||
defaults := func() facadeDiscoveryPlacementConfig {
|
||||
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
||||
}
|
||||
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||
absent, err := Load(SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
||||
Destination: options.Destination,
|
||||
PlacementMode: options.PlacementMode,
|
||||
|
||||
53
facade_dynamic_redaction_test.go
Normal file
53
facade_dynamic_redaction_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFinalDynamicTagRedactsEqualValuedResolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
type classified struct {
|
||||
Token string `yaml:"token" configkit:"secret"`
|
||||
}
|
||||
type configuration struct {
|
||||
Value any `yaml:"value"`
|
||||
}
|
||||
const canary = "DYNAMIC-TAG-CANARY"
|
||||
result, err := configkit.Load(configkit.Bytes("input", []byte("value: {token: "+canary+"}\n")), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
Resolve: func(c *configuration, _ *configkit.Resolution) error {
|
||||
// No canonical value changes, so no normalization record is needed.
|
||||
c.Value = classified{Token: canary}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(effective.String(), canary) || !strings.Contains(effective.String(), "[REDACTED]") {
|
||||
t.Fatal("effective rendering did not preserve the final dynamic tag")
|
||||
}
|
||||
explanation, found := result.Explain(configkit.Keys("value", "token"))
|
||||
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
|
||||
t.Fatal("effective explanation did not preserve the final dynamic tag")
|
||||
}
|
||||
if len(explanation.History) == 0 {
|
||||
t.Fatal("source history is absent")
|
||||
}
|
||||
for _, change := range explanation.History {
|
||||
for _, value := range []configkit.ValueSnapshot{change.Before, change.After} {
|
||||
if value.Present && (!value.Redacted || strings.Contains(value.YAML, canary)) {
|
||||
t.Fatal("historical explanation did not preserve the final dynamic tag")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ type equivalentConfig struct {
|
||||
|
||||
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
||||
bytesData := []byte(yaml)
|
||||
bytesSource := configkit.Bytes("memory", bytesData)
|
||||
bytesData[0] = 'X'
|
||||
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
||||
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||
}{
|
||||
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
||||
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
||||
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes},
|
||||
{"bytes", bytesSource, context, configkit.SourceKindBytes},
|
||||
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
||||
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
||||
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
||||
@@ -66,7 +69,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||
}
|
||||
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") {
|
||||
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerMain || info.Null != (field == "optional") {
|
||||
t.Errorf("%s input info = %#v, %v", field, info, ok)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
||||
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||
t.Fatalf("reader re-consumption = %s", got)
|
||||
}
|
||||
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
|
||||
readerCopy := copiedReader
|
||||
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
|
||||
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||
t.Fatalf("copied reader re-consumption = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
||||
@@ -120,7 +132,7 @@ func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
||||
}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
|
||||
Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }},
|
||||
Definition: configkit.Definition[config]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }},
|
||||
})
|
||||
if err != nil || result.Config.Secret != "new-secret-canary" {
|
||||
t.Fatalf("secret load = %#v, %v", result, err)
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
||||
if phase == "resolution" {
|
||||
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["items"] = items
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload")
|
||||
resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
|
||||
case "resolution":
|
||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["items"] = items
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records")
|
||||
resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
|
||||
case "resolution":
|
||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
||||
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records")
|
||||
resolution.RecordNormalization(configkit.Keys("collections"), "create namespaced records")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
120
facade_historical_paths_test.go
Normal file
120
facade_historical_paths_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
|
||||
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte(
|
||||
"retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n",
|
||||
)), configkit.Options[map[string]any]{Overrides: []configkit.Override{
|
||||
{Path: configkit.Keys("retired"), Value: nil, Flag: "--retired"},
|
||||
{Path: configkit.Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
metadata := loaded.Metadata()
|
||||
paths := metadata.HistoricalPaths()
|
||||
got := make([]string, len(paths))
|
||||
for index, path := range paths {
|
||||
got[index] = path.String()
|
||||
if len(metadata.History(path)) == 0 {
|
||||
t.Errorf("enumerated path %s has no history", path)
|
||||
}
|
||||
}
|
||||
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("historical paths = %v, want sorted unique pointers %v", got, want)
|
||||
}
|
||||
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||
t.Fatal("removed descendant remains effective")
|
||||
}
|
||||
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||
t.Fatal("enumerated numeric path did not follow the effective sequence shape")
|
||||
}
|
||||
if len(metadata.History(paths[6])) == 0 {
|
||||
t.Fatal("enumerated numeric path lost history")
|
||||
}
|
||||
assertPublicShapeHistory(t, metadata, configkit.LayerCLI)
|
||||
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
|
||||
t.Fatal("mapping-key path resolved against the effective sequence")
|
||||
}
|
||||
if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
|
||||
t.Fatal("zero metadata contains historical paths")
|
||||
}
|
||||
if (configkit.Metadata{}).History(configkit.Keys("shape")) != nil {
|
||||
t.Fatal("zero metadata contains history")
|
||||
}
|
||||
|
||||
paths[1] = configkit.Keys("mutated")
|
||||
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
|
||||
t.Fatalf("returned path slice mutated retained metadata: %v", pathStrings(again))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicHistoryUsesResolutionShapeAttribution(t *testing.T) {
|
||||
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte("shape: {'0': base}\n")), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*configuration)["shape"] = []any{"replacement"}
|
||||
resolution.RecordNormalization(configkit.Keys("shape"), "replace numeric mapping key")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
assertPublicShapeHistory(t, loaded.Metadata(), configkit.LayerResolution)
|
||||
}
|
||||
|
||||
func assertPublicShapeHistory(t *testing.T, metadata configkit.Metadata, operationLayer configkit.Layer) {
|
||||
t.Helper()
|
||||
|
||||
keyPath := configkit.Keys("shape", "0")
|
||||
indexPath := configkit.Keys("shape").Index(0)
|
||||
keyHistory := metadata.History(keyPath)
|
||||
indexHistory := metadata.History(indexPath)
|
||||
if len(keyHistory) == 0 || len(indexHistory) == 0 {
|
||||
t.Fatalf("explicit shape histories = key %#v, index %#v", keyHistory, indexHistory)
|
||||
}
|
||||
var keyDefinition, keyReplacement, indexReplacement bool
|
||||
for _, change := range keyHistory {
|
||||
keyDefinition = keyDefinition || change.Origin.Layer == configkit.LayerMain
|
||||
keyReplacement = keyReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
|
||||
}
|
||||
for _, change := range indexHistory {
|
||||
if change.Origin.Layer == configkit.LayerMain {
|
||||
t.Fatalf("introduced index inherited mapping-key history: %#v", indexHistory)
|
||||
}
|
||||
indexReplacement = indexReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
|
||||
for _, previous := range change.Previous {
|
||||
if previous.Layer == configkit.LayerMain {
|
||||
t.Fatalf("introduced index inherited mapping-key previous origin: %#v", change)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !keyDefinition || !keyReplacement || !indexReplacement {
|
||||
t.Fatalf("shape histories missed applicable definitions or replacement: key %#v, index %#v", keyHistory, indexHistory)
|
||||
}
|
||||
parsed, err := configkit.ParsePath("/shape/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsedHistory := metadata.History(parsed); len(parsedHistory) == 0 {
|
||||
t.Fatal("parsed numeric query lost contextual history")
|
||||
}
|
||||
}
|
||||
|
||||
func pathStrings(paths []configkit.Path) []string {
|
||||
result := make([]string, len(paths))
|
||||
for index, path := range paths {
|
||||
result[index] = path.String()
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||
@@ -95,7 +93,8 @@ main: main
|
||||
|
||||
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
Value string `yaml:"value"`
|
||||
Hidden string `yaml:"hidden"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
@@ -103,17 +102,23 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||
- dir: snippets
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
||||
writeFacadeImportFile(t, directory, "snippets/.hidden.cfg", "hidden: imported\n")
|
||||
|
||||
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil || unrestricted.Config.Value != "imported" {
|
||||
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
||||
}
|
||||
includeHidden := false
|
||||
visibleOnly, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{IncludeHidden: &includeHidden}})
|
||||
if err != nil || visibleOnly.Config.Value != "imported" || visibleOnly.Config.Hidden != "" {
|
||||
t.Fatalf("hidden-file default = %#v, %v", visibleOnly, err)
|
||||
}
|
||||
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
||||
if err != nil || empty.Config.Value != "" {
|
||||
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||
}
|
||||
emptySources := empty.Sources()
|
||||
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone {
|
||||
if len(emptySources) != 1 || len(emptySources[0].EmptyImportSelections) != 1 || emptySources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
|
||||
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||
}
|
||||
required := true
|
||||
@@ -122,23 +127,64 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||
}
|
||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
|
||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
||||
func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
Imported string `yaml:"imported"`
|
||||
}
|
||||
for _, layered := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
|
||||
configkit:
|
||||
imports:
|
||||
- dir: recursive-files
|
||||
- dir: parts
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
||||
recursive := true
|
||||
patterns := []string{"*.cfg"}
|
||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
||||
recursive = false
|
||||
patterns[0] = "["
|
||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
||||
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
|
||||
|
||||
recursive := true
|
||||
patterns := []string{"*.cfg"}
|
||||
definition := Definition[config]{Version: &VersionRule{
|
||||
Path: Keys("version"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
if version != (Version{Present: true, Text: "v1"}) {
|
||||
t.Errorf("version callback received %#v", version)
|
||||
}
|
||||
recursive = false
|
||||
patterns[0] = "*.skip"
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
load := func() (*Result[config], error) {
|
||||
defaults := ImportDefaults{Recursive: &recursive, Pattern: patterns}
|
||||
if layered {
|
||||
return LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, ImportDefaults: defaults,
|
||||
})
|
||||
}
|
||||
return Load(File(mainPath), Options[config]{
|
||||
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, Imports: true, ImportDefaults: defaults,
|
||||
})
|
||||
}
|
||||
|
||||
first, err := load()
|
||||
if err != nil || first.Config.Imported != "selected" {
|
||||
t.Fatalf("load with pre-callback defaults = %#v, %v", first, err)
|
||||
}
|
||||
if recursive || patterns[0] != "*.skip" {
|
||||
t.Fatalf("version callback did not mutate caller defaults: recursive=%v pattern=%q", recursive, patterns[0])
|
||||
}
|
||||
|
||||
second, err := load()
|
||||
if err != nil || second.Config.Imported != "" {
|
||||
t.Fatalf("subsequent load reused earlier defaults: %#v, %v", second, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,15 +237,15 @@ value: loaded
|
||||
callbackPath := ""
|
||||
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
first, ok := resolution.Source("source-1")
|
||||
if !ok || len(first.ImportSelections) != 1 {
|
||||
if !ok || len(first.EmptyImportSelections) != 1 {
|
||||
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||
}
|
||||
first.ImportSelections[0].Path = Keys("callback mutation")
|
||||
first.EmptyImportSelections[0].Path = Keys("callback mutation")
|
||||
second, ok := resolution.Source("source-1")
|
||||
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
if !ok || second.EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||
}
|
||||
callbackPath = second.ImportSelections[0].Path.String()
|
||||
callbackPath = second.EmptyImportSelections[0].Path.String()
|
||||
return nil
|
||||
}}})
|
||||
if err != nil {
|
||||
@@ -209,11 +255,11 @@ value: loaded
|
||||
t.Fatalf("callback path = %q", callbackPath)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory {
|
||||
if len(sources) != 1 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Location.SourceID != sources[0].ID || sources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
|
||||
t.Fatalf("result source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].ImportSelections[0].Path = Keys("result mutation")
|
||||
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
|
||||
if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||
}
|
||||
|
||||
@@ -229,11 +275,11 @@ value: loaded
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
errorSources := loadError.Sources()
|
||||
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
if len(errorSources) != 1 || len(errorSources[0].EmptyImportSelections) != 1 || errorSources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("error source evidence = %#v", errorSources)
|
||||
}
|
||||
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
|
||||
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
|
||||
if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ entries:
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||
Secret: func(candidate Path) bool {
|
||||
Sensitive: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
@@ -67,6 +67,14 @@ entries:
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
if len(sources) < 3 || sources[0].Layer != LayerMain || string(sources[0].Layer) != "main" {
|
||||
t.Fatalf("main source attribution = %#v", sources)
|
||||
}
|
||||
for _, source := range sources[1:] {
|
||||
if source.ImportedFrom != nil && (source.Layer != LayerMain || string(source.Layer) != "main") {
|
||||
t.Errorf("imported main source attribution = %#v", source)
|
||||
}
|
||||
}
|
||||
filesByID := make(map[string]string, len(sources))
|
||||
for _, source := range sources {
|
||||
filesByID[source.ID] = source.Path
|
||||
@@ -77,8 +85,8 @@ entries:
|
||||
itemSource = source
|
||||
}
|
||||
}
|
||||
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
|
||||
if itemSource.VersionEvidence == nil || !itemSource.VersionEvidence.Version.Present || !itemSource.VersionEvidence.Redacted || itemSource.VersionEvidence.Version.Text != "" {
|
||||
t.Fatalf("consumed header inspection = %#v", itemSource.VersionEvidence)
|
||||
}
|
||||
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
||||
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||
@@ -107,7 +115,7 @@ entries:
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||
Secret: func(candidate Path) bool {
|
||||
Sensitive: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
@@ -129,7 +137,7 @@ entries:
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
|
||||
if !foundFailedItem || failedItemSource.VersionEvidence == nil || failedItemSource.VersionEvidence.Version.Text != "" || !failedItemSource.VersionEvidence.Redacted {
|
||||
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
||||
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
|
||||
t.Error("inherited false is not explicit")
|
||||
}
|
||||
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled"))
|
||||
info, found := resolution.EntryInfo(configkit.Keys("entries", "child", "enabled"))
|
||||
if !found || len(info.Origin.Inheritance) != 1 {
|
||||
t.Errorf("resolver inherited origin = %#v, %v", info, found)
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testin
|
||||
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: registrations,
|
||||
Secret: func(path configkit.Path) bool {
|
||||
Sensitive: func(path configkit.Path) bool {
|
||||
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
||||
},
|
||||
},
|
||||
@@ -221,7 +221,7 @@ func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.
|
||||
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||
Sensitive: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||
},
|
||||
})
|
||||
if err == nil || strings.Contains(err.Error(), canary) {
|
||||
@@ -468,7 +468,7 @@ func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
|
||||
Entries map[string]entry `yaml:"entries"`
|
||||
}
|
||||
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
||||
Sensitive: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
||||
}})
|
||||
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
|
||||
t.Fatalf("inherited credential = %#v, %v", result, err)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
data := []byte("value: retained\n")
|
||||
source := Bytes("snapshot.yml", data)
|
||||
data[7] = 'X'
|
||||
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
|
||||
if err != nil || result.Config.Value != "retained" {
|
||||
t.Fatalf("snapshotted source = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
|
||||
first := readerSource
|
||||
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
|
||||
var loadError *engine.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
|
||||
t.Fatalf("copied reader source error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parsed, err := ParsePath("/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyed, indexed := Keys("0"), Path{}.Index(0)
|
||||
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
|
||||
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
|
||||
}
|
||||
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
|
||||
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,8 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
|
||||
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
|
||||
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
|
||||
Sensitive: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -80,7 +80,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
|
||||
if len(sources) != 8 {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
|
||||
wantLayers := []configkit.Layer{configkit.LayerMain, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
|
||||
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
|
||||
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
|
||||
for index, source := range sources {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
|
||||
if !reflect.DeepEqual(result.Config, want) {
|
||||
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" {
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerMain || sources[0].Path != "" {
|
||||
t.Fatalf("mapping sources = %#v", sources)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
|
||||
@@ -89,7 +89,7 @@ func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
|
||||
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
|
||||
t.Fatalf("mapping import = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase {
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerMain {
|
||||
t.Fatalf("mapping import sources = %#v", sources)
|
||||
}
|
||||
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
|
||||
|
||||
63
facade_resolution_presence_test.go
Normal file
63
facade_resolution_presence_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeResolutionPresenceUsesDynamicEntryShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type configuration struct {
|
||||
Shape any `yaml:"shape"`
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
keyPresent bool
|
||||
}{
|
||||
{name: "mapping key", input: "shape: {'0': input}\n", keyPresent: true},
|
||||
{name: "sequence index", input: "shape: [input]\n", keyPresent: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
parsed, err := configkit.ParsePath("/shape/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = configkit.Load(configkit.Bytes("dynamic.yml", []byte(test.input)), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
Resolve: func(_ *configuration, resolution *configkit.Resolution) error {
|
||||
check := func(path configkit.Path, want bool) error {
|
||||
info, found := resolution.EntryInfo(path)
|
||||
explicit := resolution.IsExplicit(path)
|
||||
if found != want || explicit != want || (found && info.Explicit != explicit) {
|
||||
return errors.New("resolution presence disagrees with dynamic entry shape")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, checkPath := range []struct {
|
||||
path configkit.Path
|
||||
want bool
|
||||
}{
|
||||
{path: configkit.Keys("shape", "0"), want: test.keyPresent},
|
||||
{path: configkit.Keys("shape").Index(0), want: !test.keyPresent},
|
||||
{path: parsed, want: true},
|
||||
{path: configkit.Keys("shape", "missing"), want: false},
|
||||
} {
|
||||
if err := check(checkPath.path, checkPath.want); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
facade_resolution_recording_test.go
Normal file
43
facade_resolution_recording_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package configkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFacadeResolutionRecordingIntents(t *testing.T) {
|
||||
type configuration struct {
|
||||
Derived string `yaml:"derived"`
|
||||
Normalized string `yaml:"normalized"`
|
||||
}
|
||||
|
||||
result, err := Load(Bytes("recording.yml", []byte("derived: explicit\nnormalized: before\n")), Options[configuration]{
|
||||
Definition: Definition[configuration]{Resolve: func(value *configuration, resolution *Resolution) error {
|
||||
value.Derived = "changed explicit input"
|
||||
resolution.RecordDerivedDefault(Keys("derived"), "derive explicit input")
|
||||
value.Normalized = "after"
|
||||
resolution.RecordNormalization(Keys("normalized"), "normalize configured input")
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
path Path
|
||||
kind ChangeKind
|
||||
}{
|
||||
{path: Keys("derived"), kind: ChangeDerivedDefault},
|
||||
{path: Keys("normalized"), kind: ChangeNormalization},
|
||||
} {
|
||||
info, ok := result.Metadata().Lookup(test.path)
|
||||
if !ok || info.Origin.Operation != test.kind {
|
||||
t.Errorf("metadata %s = %#v, %v; want operation %q", test.path, info, ok, test.kind)
|
||||
}
|
||||
history := result.Metadata().History(test.path)
|
||||
if len(history) == 0 || history[len(history)-1].Origin.Operation != test.kind {
|
||||
t.Errorf("history %s = %#v; want final operation %q", test.path, history, test.kind)
|
||||
}
|
||||
}
|
||||
if !result.Metadata().IsExplicit(Keys("derived")) {
|
||||
t.Fatal("recording a derived default suppressed explicit-input provenance")
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||
t.Fatalf("removal not explained: %#v", explanation)
|
||||
}
|
||||
last := explanation.History[len(explanation.History)-1]
|
||||
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerBase || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
|
||||
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerMain || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
|
||||
t.Fatalf("wrong removal transition/origin: %#v", last)
|
||||
}
|
||||
})
|
||||
@@ -211,7 +211,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
|
||||
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
|
||||
}
|
||||
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved {
|
||||
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationIsFallback {
|
||||
t.Fatalf("ordinary item source = %#v", sources)
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||
t.Fatalf("layered item placement = %#v", layered.Config)
|
||||
}
|
||||
for _, source := range layered.Sources() {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
|
||||
t.Errorf("layered item source = %#v", source)
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ items:
|
||||
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
||||
}
|
||||
for index, source := range callbackSources {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationIsFallback {
|
||||
t.Errorf("callback source = %#v", source)
|
||||
}
|
||||
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
||||
@@ -349,7 +349,7 @@ items:
|
||||
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||
}
|
||||
}
|
||||
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
|
||||
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationIsFallback {
|
||||
t.Fatalf("final item sources = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -372,9 +372,9 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||
t.Fatalf("early failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
|
||||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
||||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
|
||||
sources[1].Destination.String() != "/items" || !sources[1].DestinationIsFallback ||
|
||||
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("early source snapshots = %#v", sources)
|
||||
}
|
||||
@@ -399,13 +399,13 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||
t.Fatalf("post-placement failure = %v", err)
|
||||
}
|
||||
sources = loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
|
||||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationIsFallback {
|
||||
t.Fatalf("post-placement source snapshots = %#v", sources)
|
||||
}
|
||||
firstSnapshot := sources[1]
|
||||
sources[1].Destination = configkit.Keys("mutated")
|
||||
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
|
||||
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationIsFallback {
|
||||
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("successful item load: %v", err)
|
||||
}
|
||||
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationIsFallback || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("callback source snapshot = %#v", callback)
|
||||
}
|
||||
}
|
||||
@@ -450,13 +450,13 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("nested source snapshots = %#v", sources)
|
||||
}
|
||||
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("outer item snapshot = %#v", got)
|
||||
}
|
||||
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("nested item fallback = %#v", got)
|
||||
}
|
||||
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
|
||||
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Errorf("nested helper fallback = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -496,7 +496,7 @@ func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
|
||||
|
||||
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
||||
zero := configkit.SourceInfo{}
|
||||
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
|
||||
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationIsFallback {
|
||||
t.Fatalf("zero source info = %#v", zero)
|
||||
}
|
||||
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
|
||||
@@ -504,12 +504,12 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
|
||||
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationIsFallback {
|
||||
t.Fatalf("mapping source evidence = %#v", sources)
|
||||
}
|
||||
first := sources[0]
|
||||
first.Destination = configkit.Keys("caller")
|
||||
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
|
||||
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationIsFallback {
|
||||
t.Fatalf("source evidence changed after caller mutation: %#v", again)
|
||||
}
|
||||
}
|
||||
@@ -598,7 +598,7 @@ entries:
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
Sensitive: func(path configkit.Path) bool {
|
||||
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
|
||||
},
|
||||
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
|
||||
@@ -611,8 +611,8 @@ entries:
|
||||
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
|
||||
t.Error("resolver treated an omitted field as explicit")
|
||||
}
|
||||
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count"))
|
||||
if !found || info.Origin.Layer != configkit.LayerBase {
|
||||
info, found := resolution.EntryInfo(configkit.Keys("entries").Index(0).Key("count"))
|
||||
if !found || info.Origin.Layer != configkit.LayerMain {
|
||||
t.Errorf("resolver current-record input info = %#v, %v", info, found)
|
||||
}
|
||||
for _, id := range []string{"source-3", "source-4"} {
|
||||
@@ -646,7 +646,7 @@ entries:
|
||||
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
|
||||
}
|
||||
for _, source := range callbackItems {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
|
||||
t.Errorf("callback item source = %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
|
||||
t.Fatal("unrelated underlay payload was treated as the main declaration")
|
||||
}
|
||||
for _, source := range loaded.Sources() {
|
||||
if source.Layer == configkit.LayerBase && source.VersionCheck.Path.String() != path.String() {
|
||||
if source.Layer == configkit.LayerMain && source.VersionEvidence.Path.String() != path.String() {
|
||||
t.Fatal("effective movement changed source-local version evidence")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||
Sensitive: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||
}
|
||||
first := "records: [{id: first, version: first-version}]\n"
|
||||
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
||||
@@ -61,7 +61,7 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
check := sources[len(sources)-1].VersionCheck
|
||||
check := sources[len(sources)-1].VersionEvidence
|
||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||
t.Fatalf("source-local evidence changed: %#v", check)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
foundMapping = true
|
||||
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||
if check := source.VersionEvidence; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
{"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
|
||||
var callbackEvidence *configkit.SourceVersionEvidence
|
||||
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]{
|
||||
@@ -275,7 +275,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
if !found {
|
||||
t.Fatal("missing callback source")
|
||||
}
|
||||
callbackEvidence = source.VersionCheck
|
||||
callbackEvidence = source.VersionEvidence
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -283,7 +283,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
var evidence *configkit.SourceVersionCheck
|
||||
var evidence *configkit.SourceVersionEvidence
|
||||
if mode == configkit.SourceVersionModeRequired {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
@@ -293,12 +293,12 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
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
|
||||
evidence = failure.Sources()[1].VersionEvidence
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", test.name, err)
|
||||
}
|
||||
evidence = loaded.Sources()[1].VersionCheck
|
||||
evidence = loaded.Sources()[1].VersionEvidence
|
||||
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||
t.Errorf("%s: callback path is incomplete", test.name)
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
|
||||
if child["value"] != "good" {
|
||||
t.Fatal("valid sibling inheritance was not applied")
|
||||
}
|
||||
check := loaded.Sources()[1].VersionCheck
|
||||
check := loaded.Sources()[1].VersionEvidence
|
||||
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
||||
t.Fatal("optional omission evidence was not retained")
|
||||
}
|
||||
@@ -544,16 +544,16 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
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" },
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Sensitive: 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)
|
||||
if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
|
||||
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionEvidence)
|
||||
}
|
||||
callbackContext, found = resolution.SourceContext("source-1")
|
||||
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
@@ -568,17 +568,17 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
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)
|
||||
if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
|
||||
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionEvidence)
|
||||
}
|
||||
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
|
||||
callbackSource.VersionCheck.Version.Text = canary
|
||||
callbackSource.VersionCheck.Location.Line = 98
|
||||
callbackSource.VersionEvidence.Path = configkit.Keys("mutated-callback")
|
||||
callbackSource.VersionEvidence.Version.Text = canary
|
||||
callbackSource.VersionEvidence.Location.Line = 98
|
||||
finalSources := loaded.Sources()
|
||||
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
|
||||
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionEvidence == nil {
|
||||
t.Fatalf("final sources = %#v", finalSources)
|
||||
}
|
||||
check := finalSources[0].VersionCheck
|
||||
check := finalSources[0].VersionEvidence
|
||||
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)
|
||||
@@ -594,9 +594,9 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
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 != "" {
|
||||
if len(repeated) != 1 || repeated[0].VersionEvidence.Path.String() != "/version" ||
|
||||
repeated[0].VersionEvidence.Location.Line != 1 || !repeated[0].VersionEvidence.Redacted ||
|
||||
repeated[0].VersionEvidence.Version.Text != "" {
|
||||
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
||||
}
|
||||
|
||||
@@ -612,14 +612,14 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
|
||||
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionEvidence == 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 {
|
||||
if got := public.Sources()[0].VersionEvidence; 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)
|
||||
if publicCallback.VersionEvidence == nil || !publicCallback.VersionEvidence.Redacted || publicCallback.VersionEvidence.Version.Text != "" {
|
||||
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,7 +650,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
if checked != 1 {
|
||||
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
check := failure.Sources()[0].VersionEvidence
|
||||
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())
|
||||
}
|
||||
@@ -666,14 +666,14 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
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" {
|
||||
if len(sources) != 1 || sources[0].VersionEvidence == nil || !sources[0].VersionEvidence.Redacted ||
|
||||
!sources[0].VersionEvidence.Version.Present || sources[0].VersionEvidence.Version.Text != "" || sources[0].VersionEvidence.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 {
|
||||
sources[0].VersionEvidence.Path = configkit.Keys("mutated-error")
|
||||
sources[0].VersionEvidence.Version.Text = "mutated"
|
||||
sources[0].VersionEvidence.Location.Line = 99
|
||||
if again := loadError.Sources()[0].VersionEvidence; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
||||
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
@@ -707,7 +707,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
if strings.Contains(err.Error(), test.canary) {
|
||||
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
check := failure.Sources()[0].VersionEvidence
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
||||
}
|
||||
@@ -715,7 +715,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
}
|
||||
|
||||
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 {
|
||||
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionEvidence != nil {
|
||||
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
||||
}
|
||||
|
||||
@@ -728,7 +728,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
||||
}
|
||||
sawCLI := false
|
||||
for _, source := range withCLI.Sources() {
|
||||
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
|
||||
if source.Kind == configkit.SourceKindCLI && source.VersionEvidence != nil {
|
||||
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
||||
}
|
||||
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
||||
@@ -764,14 +764,14 @@ func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
|
||||
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 {
|
||||
if len(sources) != 3 || sources[1].VersionEvidence == nil || sources[2].VersionEvidence == nil {
|
||||
t.Fatalf("source version evidence = %#v", sources)
|
||||
}
|
||||
absent := sources[1].VersionCheck
|
||||
absent := sources[1].VersionEvidence
|
||||
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
||||
t.Fatalf("optional omission evidence = %#v", absent)
|
||||
}
|
||||
null := sources[2].VersionCheck
|
||||
null := sources[2].VersionEvidence
|
||||
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
||||
t.Fatalf("null declaration evidence = %#v", null)
|
||||
}
|
||||
@@ -807,7 +807,7 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
|
||||
},
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Service.Resolved = "resolved once"
|
||||
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
|
||||
resolution.RecordNormalization(configkit.Keys("service", "resolved"), "derive resolved value")
|
||||
return nil
|
||||
},
|
||||
Validate: func(current *configuration, validation *configkit.Validation) {
|
||||
@@ -865,8 +865,8 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
|
||||
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" {
|
||||
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionEvidence == nil || sourcesB[1].VersionEvidence == nil ||
|
||||
sourcesA[1].VersionEvidence.Version.Text != "underlay-release-a" || sourcesB[1].VersionEvidence.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"))
|
||||
|
||||
50
facade_tier_history_test.go
Normal file
50
facade_tier_history_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestTierHistoryRecordsEachContributionOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, middle := range []string{"mid", "low"} {
|
||||
t.Run(middle, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](
|
||||
configkit.Input{Source: configkit.Bytes("main", []byte("x: high\n"))},
|
||||
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{Source: configkit.Bytes("low", []byte("x: low\n"))}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Bytes("middle", []byte("x: "+middle+"\n"))}}},
|
||||
}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history := result.Metadata().History(configkit.Keys("x"))
|
||||
if len(history) != 3 {
|
||||
t.Fatalf("history has %d records; want the three independent contributions", len(history))
|
||||
}
|
||||
explanation, found := result.Explain(configkit.Keys("x"))
|
||||
if !found || len(explanation.History) != len(history) {
|
||||
t.Fatal("explanation lost ordered tier contributions")
|
||||
}
|
||||
for index, sourceID := range []string{"source-2", "source-3", "source-1"} {
|
||||
change := history[index]
|
||||
if len(change.Origin.Locations) != 1 || change.Origin.Locations[0].SourceID != sourceID {
|
||||
t.Fatalf("contribution %d has origin %#v", index, change.Origin)
|
||||
}
|
||||
if len(explanation.History[index].Origin.Locations) != 1 || explanation.History[index].Origin.Locations[0] != change.Origin.Locations[0] {
|
||||
t.Fatalf("explanation contribution %d has a different source origin", index)
|
||||
}
|
||||
if index == 0 {
|
||||
if len(change.Previous) != 0 {
|
||||
t.Fatal("first source contribution has a previous origin")
|
||||
}
|
||||
} else if len(change.Previous) != 1 || len(change.Previous[0].Locations) != 1 ||
|
||||
change.Previous[0].Locations[0] != history[index-1].Origin.Locations[0] {
|
||||
t.Fatalf("contribution %d has incorrect previous origin %#v", index, change.Previous)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
|
||||
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
for _, pointer := range paths {
|
||||
path, _ := configkit.ParsePath(pointer)
|
||||
info, ok := resolution.InputInfo(path)
|
||||
info, ok := resolution.EntryInfo(path)
|
||||
if !ok {
|
||||
t.Errorf("missing resolver input %s", pointer)
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -5,7 +5,7 @@ go 1.26.0
|
||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
|
||||
@@ -3,7 +3,7 @@ package engine
|
||||
import "strconv"
|
||||
|
||||
type acquisitionPolicy struct {
|
||||
importDefaults importDefaults
|
||||
importDefaults ImportDefaults
|
||||
sequencePolicy sequencePolicy
|
||||
importLimits importLimits
|
||||
}
|
||||
@@ -23,7 +23,6 @@ type inputAcquisitionRequest struct {
|
||||
role sourceVersionRole
|
||||
directiveContext rootDirectiveContext
|
||||
protectionRule *VersionRule
|
||||
deferAssembly bool
|
||||
ordinaryOverlay string
|
||||
}
|
||||
|
||||
@@ -38,9 +37,6 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
||||
destination := request.input.destination
|
||||
accounting := state.accounting
|
||||
versionState := state.versionState
|
||||
visited := tier.visited
|
||||
hostToken := tier.hostToken
|
||||
role := request.role
|
||||
sourceID := "source-" + strconv.Itoa(accounting.sources+1)
|
||||
selectedSource := source
|
||||
if source.kind == sourceSearch {
|
||||
@@ -99,67 +95,10 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
||||
return input, directiveDiagnostics
|
||||
}
|
||||
}
|
||||
var versionProcessing *sourceVersionProcessingResult
|
||||
if directiveContext == rootDirectiveContextSource {
|
||||
var sourceID string
|
||||
if parsed.source != nil {
|
||||
sourceID = parsed.source.ID
|
||||
}
|
||||
var processed sourceVersionProcessingResult
|
||||
var failures []Diagnostic
|
||||
if versionState != nil {
|
||||
processed, failures = versionState.processWithDirectiveContext(parsed.document, role, destination, sourceID, request.input.placementMode, directiveContext)
|
||||
} else {
|
||||
processed = processSourceVersionWithDirectiveContext(parsed.document, request.protectionRule, role, destination, sourceVersionModeDefault, directiveContext)
|
||||
failures = processed.diagnostics
|
||||
}
|
||||
if len(failures) != 0 {
|
||||
return input, failures
|
||||
}
|
||||
versionProcessing = &processed
|
||||
if role == sourceVersionMain && versionState != nil {
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
} else if directiveContext == rootDirectiveContextLayered {
|
||||
extracted, failures := extractRootDirectives(parsed.document, rootDirectiveContextLayered)
|
||||
if len(failures) != 0 {
|
||||
return input, failures
|
||||
}
|
||||
processed := sourceVersionProcessingResult{
|
||||
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
|
||||
directiveContext: rootDirectiveContextLayered,
|
||||
}
|
||||
if versionState != nil && versionState.mode != sourceVersionModeDefault {
|
||||
var sourceID string
|
||||
if parsed.source != nil {
|
||||
sourceID = parsed.source.ID
|
||||
}
|
||||
versionProcessed, versionFailures := versionState.process(extracted.body, role, destination, sourceID, request.input.placementMode)
|
||||
if len(versionFailures) != 0 {
|
||||
return input, versionFailures
|
||||
}
|
||||
versionProcessed.imports = extracted.imports
|
||||
versionProcessed.sequences = append(cloneSequenceDeclarations(extracted.sequences), versionProcessed.sequences...)
|
||||
versionProcessed.directiveContext = rootDirectiveContextLayered
|
||||
processed = versionProcessed
|
||||
if role == sourceVersionMain {
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
}
|
||||
versionProcessing = &processed
|
||||
} else 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, request.input.placementMode)
|
||||
if len(failures) != 0 {
|
||||
return input, failures
|
||||
}
|
||||
versionProcessing = &processed
|
||||
if role == sourceVersionMain {
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
request.directiveContext = directiveContext
|
||||
versionProcessing, diagnostics := processAcquiredInputVersion(parsed, request, versionState)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
request.input.source = selectedSource
|
||||
if request.input.imports && selectedSource.kind != sourceNone {
|
||||
@@ -173,69 +112,168 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
||||
input.document = input.composition.root
|
||||
}
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if versionProcessing != nil {
|
||||
input.document = versionProcessing.payload
|
||||
}
|
||||
if request.input.filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||
resolved, diagnostic := validateFilesystemContext(selectedSource, request.input.filesystem, parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.resolvedFilesystem = &resolved
|
||||
input, diagnostics = acquireStandaloneInput(input, request, versionProcessing, policy, tier)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
}
|
||||
if !request.input.imports && input.document != nil {
|
||||
_, placed, err := placeDocumentWithMode(input.document, destination, request.input.placementMode, 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")}
|
||||
}
|
||||
localPayload := input.document
|
||||
input.document = placed
|
||||
identity := importKeyForMainSource(parsed.source)
|
||||
var sequenceDeclarations []sequenceDeclarationSpec
|
||||
if versionProcessing != nil {
|
||||
sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences)
|
||||
}
|
||||
input.acquisition = []acquisitionScope{{
|
||||
owner: identity,
|
||||
destination: destination.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||
document: placed, destination: destination.clone(), processing: versionProcessing,
|
||||
sequenceDeclarations: sequenceDeclarations,
|
||||
}},
|
||||
}}
|
||||
rawNodes := countInputNodes(parsed.document)
|
||||
placementNodes := destinationWrapperNodeCountForMode(destination, request.input.placementMode)
|
||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||
}
|
||||
if diagnostic := registerAcquiredSource(selectedSource, request.input.filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += parsed.bytes
|
||||
accounting.nodes += rawNodes + placementNodes
|
||||
return completeAcquisitionContexts(input, request, tier)
|
||||
}
|
||||
|
||||
func acquireStandaloneInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||
parsed := input.parsed
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if processing != nil {
|
||||
input.document = processing.payload
|
||||
}
|
||||
if !activeOverlay(request.ordinaryOverlay) || request.input.imports {
|
||||
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (selectedSource.kind == sourceFile || request.input.filesystem != nil || nativeContext(selectedSource, request.input.filesystem)) {
|
||||
resolved, diagnostic := resolveFilesystemContext(selectedSource, request.input.filesystem, parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.resolvedFilesystem = &resolved
|
||||
}
|
||||
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
|
||||
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.contexts = contexts
|
||||
input.resolvedFilesystem = &resolved
|
||||
}
|
||||
if request.input.imports || input.document == nil {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
limits := policy.importLimits
|
||||
placement := request.input.destination
|
||||
documentLimits := defaultParseLimits().document
|
||||
_, placed, err := placeDocumentWithMode(input.document, placement, request.input.placementMode, input.document.Location, documentLimits.Depth, documentLimits.Nodes)
|
||||
if err != nil {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
}
|
||||
localPayload := input.document
|
||||
input.document = placed
|
||||
identity := importKeyForMainSource(parsed.source)
|
||||
var sequenceDeclarations []sequenceDeclarationSpec
|
||||
if processing != nil {
|
||||
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
|
||||
}
|
||||
input.acquisition = []acquisitionScope{{
|
||||
owner: identity,
|
||||
destination: placement.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||
document: placed, destination: placement.clone(), processing: processing,
|
||||
sequenceDeclarations: sequenceDeclarations,
|
||||
}},
|
||||
}}
|
||||
rawNodes := countInputNodes(parsed.document)
|
||||
placementNodes := destinationWrapperNodeCountForMode(placement, request.input.placementMode)
|
||||
accounting := input.accounting
|
||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||
}
|
||||
if diagnostic := registerAcquiredSource(input.source, request.input.filesystem, parsed.source, tier.visited, tier.hostToken); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += parsed.bytes
|
||||
accounting.nodes += rawNodes + placementNodes
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func completeAcquisitionContexts(input assembledInput, request inputAcquisitionRequest, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||
if activeOverlay(request.ordinaryOverlay) && !request.input.imports {
|
||||
return input, nil
|
||||
}
|
||||
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (input.source.kind == sourceFile || request.input.filesystem != nil || nativeContext(input.source, request.input.filesystem)) {
|
||||
resolved, diagnostic := resolveFilesystemContext(input.source, request.input.filesystem, input.parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.resolvedFilesystem = &resolved
|
||||
}
|
||||
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.contexts = contexts
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRequest, state *sourceVersionState) (*sourceVersionProcessingResult, []Diagnostic) {
|
||||
sourceID := ""
|
||||
if parsed.source != nil {
|
||||
sourceID = parsed.source.ID
|
||||
}
|
||||
|
||||
var result sourceVersionProcessingResult
|
||||
hasResult := false
|
||||
updateMainProcessing := false
|
||||
switch request.directiveContext {
|
||||
case rootDirectiveContextSource:
|
||||
if state != nil {
|
||||
processed, diagnostics := state.processWithDirectiveContext(
|
||||
parsed.document, request.role, request.input.destination, sourceID,
|
||||
request.input.placementMode, request.directiveContext,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
result = processed
|
||||
updateMainProcessing = request.role == sourceVersionMain
|
||||
} else {
|
||||
result = processSourceVersionWithDirectiveContext(
|
||||
parsed.document, request.protectionRule, request.role,
|
||||
request.input.destination, sourceVersionModeDefault, request.directiveContext,
|
||||
)
|
||||
if len(result.diagnostics) != 0 {
|
||||
return nil, result.diagnostics
|
||||
}
|
||||
}
|
||||
hasResult = true
|
||||
case rootDirectiveContextLayered:
|
||||
extracted, diagnostics := extractRootDirectives(parsed.document, request.directiveContext)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
result = sourceVersionProcessingResult{
|
||||
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
|
||||
directiveContext: request.directiveContext,
|
||||
}
|
||||
hasResult = true
|
||||
if state != nil && state.mode != sourceVersionModeDefault {
|
||||
processed, diagnostics := state.process(
|
||||
extracted.body, request.role, request.input.destination,
|
||||
sourceID, request.input.placementMode,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
processed.imports = extracted.imports
|
||||
processed.sequences = append(cloneSequenceDeclarations(extracted.sequences), processed.sequences...)
|
||||
processed.directiveContext = request.directiveContext
|
||||
result = processed
|
||||
updateMainProcessing = request.role == sourceVersionMain
|
||||
}
|
||||
default:
|
||||
if state != nil && state.mode != sourceVersionModeDefault {
|
||||
processed, diagnostics := state.process(
|
||||
parsed.document, request.role, request.input.destination,
|
||||
sourceID, request.input.placementMode,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
result = processed
|
||||
hasResult = true
|
||||
updateMainProcessing = request.role == sourceVersionMain
|
||||
}
|
||||
}
|
||||
if !hasResult {
|
||||
return nil, nil
|
||||
}
|
||||
if updateMainProcessing {
|
||||
completed := result
|
||||
state.mainProcessing = &completed
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
|
||||
if visited == nil || info == nil || info.Path == "" {
|
||||
return nil
|
||||
|
||||
@@ -20,9 +20,9 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
|
||||
definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||
Normalize(r, Keys("map"), &c.Map, shared, "populate")
|
||||
c.Pointer = external
|
||||
r.Record(Keys("pointer"), ChangeNormalization, "populate")
|
||||
r.RecordNormalization(Keys("pointer"), "populate")
|
||||
c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET")
|
||||
r.Record(Keys("secret"), ChangeNormalization, "reference")
|
||||
r.RecordNormalization(Keys("secret"), "reference")
|
||||
return nil
|
||||
}}
|
||||
if validatorAssignment {
|
||||
|
||||
73
internal/engine/callback_panic_test.go
Normal file
73
internal/engine/callback_panic_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func capturePanic(call func()) (recovered any) {
|
||||
defer func() { recovered = recover() }()
|
||||
call()
|
||||
return nil
|
||||
}
|
||||
|
||||
func requirePanicValue(t *testing.T, want any, call func()) {
|
||||
t.Helper()
|
||||
if got := capturePanic(call); got != want {
|
||||
t.Fatalf("panic = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackContextsCloseWhenCallbacksPanic(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
|
||||
t.Run("resolution", func(t *testing.T) {
|
||||
sentinel := errors.New("resolution panic sentinel")
|
||||
var retained *Resolution
|
||||
got := capturePanic(func() {
|
||||
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
retained = resolution
|
||||
panic(sentinel)
|
||||
}}})
|
||||
})
|
||||
if got != sentinel {
|
||||
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||
}
|
||||
requirePanicValue(t, closedCallbackContextMessage, func() { retained.IsExplicit(Keys("value")) })
|
||||
})
|
||||
|
||||
t.Run("validation", func(t *testing.T) {
|
||||
sentinel := errors.New("validation panic sentinel")
|
||||
var retained *Validation
|
||||
got := capturePanic(func() {
|
||||
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) {
|
||||
retained = validation
|
||||
panic(sentinel)
|
||||
}}})
|
||||
})
|
||||
if got != sentinel {
|
||||
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||
}
|
||||
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Error(Keys("value"), "too late") })
|
||||
})
|
||||
|
||||
t.Run("version", func(t *testing.T) {
|
||||
sentinel := errors.New("version panic sentinel")
|
||||
var retained *Validation
|
||||
got := capturePanic(func() {
|
||||
_, _ = testLoad[config](Bytes("version.yml", []byte("version: v1\n")), Options[config]{Definition: Definition[config]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
||||
retained = validation
|
||||
panic(sentinel)
|
||||
}},
|
||||
}})
|
||||
})
|
||||
if got != sentinel {
|
||||
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||
}
|
||||
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Warning(Keys("version"), "too late") })
|
||||
})
|
||||
}
|
||||
@@ -14,11 +14,7 @@ type collectionPolicy struct {
|
||||
type collectionPolicyResolution struct {
|
||||
policy collectionPolicy
|
||||
operation sequenceMode
|
||||
declarations []sequenceDeclarationSpec
|
||||
declarationsPresent bool
|
||||
identityDeclarationPresent bool
|
||||
modeDeclarationPresent bool
|
||||
applicationIdentityFields []string
|
||||
applicationIdentityPresent bool
|
||||
effectiveIdentityFields []string
|
||||
}
|
||||
@@ -60,9 +56,6 @@ func resolveCollectionPolicy(
|
||||
}
|
||||
}
|
||||
result := collectionPolicyResolution{
|
||||
declarations: normalizedDeclarations,
|
||||
declarationsPresent: len(normalizedDeclarations) != 0,
|
||||
applicationIdentityFields: normalizedApplicationIdentityFields,
|
||||
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
||||
}
|
||||
|
||||
@@ -73,7 +66,6 @@ func resolveCollectionPolicy(
|
||||
var declaredModeLocation *Location
|
||||
for _, declaration := range normalizedDeclarations {
|
||||
if declaration.identityPresent {
|
||||
result.identityDeclarationPresent = true
|
||||
if declaredIdentityFields == nil {
|
||||
declaredIdentityFields = cloneStrings(declaration.identityFields)
|
||||
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)
|
||||
|
||||
137
internal/engine/collection_policy_index.go
Normal file
137
internal/engine/collection_policy_index.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package engine
|
||||
|
||||
// collectionPolicyRetirementIndex locates only assembly policies whose YAML
|
||||
// identity or record-owned state can be cleared by an ancestor replacement.
|
||||
// Mapping keys and sequence indexes remain distinct through pathSegment keys.
|
||||
type collectionPolicyRetirementIndex struct {
|
||||
children map[pathSegment]*collectionPolicyRetirementIndex
|
||||
entryKey string
|
||||
hasEntry bool
|
||||
}
|
||||
|
||||
func newCollectionPolicyRetirementIndex(scope collectionPolicyScope) *collectionPolicyRetirementIndex {
|
||||
index := &collectionPolicyRetirementIndex{}
|
||||
for _, entry := range scope.collections {
|
||||
if collectionPolicyHasRetirementState(entry.policy) {
|
||||
index.set(entry)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) set(entry collectionPolicyEntry) {
|
||||
node := index
|
||||
for _, segment := range entry.destination.segments {
|
||||
if node.children == nil {
|
||||
node.children = make(map[pathSegment]*collectionPolicyRetirementIndex)
|
||||
}
|
||||
if node.children[segment] == nil {
|
||||
node.children[segment] = &collectionPolicyRetirementIndex{}
|
||||
}
|
||||
node = node.children[segment]
|
||||
}
|
||||
node.entryKey = entry.destination.String()
|
||||
node.hasEntry = true
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) remove(destination Path) {
|
||||
if index == nil {
|
||||
return
|
||||
}
|
||||
nodes := make([]*collectionPolicyRetirementIndex, 1, len(destination.segments)+1)
|
||||
nodes[0] = index
|
||||
node := index
|
||||
for _, segment := range destination.segments {
|
||||
node = node.children[segment]
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
node.entryKey = ""
|
||||
node.hasEntry = false
|
||||
pruneCollectionPolicyIndex(nodes, destination.segments)
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) retire(scope *collectionPolicyScope, ancestor Path) {
|
||||
if index == nil || scope == nil {
|
||||
return
|
||||
}
|
||||
nodes := make([]*collectionPolicyRetirementIndex, 1, len(ancestor.segments)+1)
|
||||
nodes[0] = index
|
||||
node := index
|
||||
for _, segment := range ancestor.segments {
|
||||
node = node.children[segment]
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
index.clearSubtree(scope, node)
|
||||
pruneCollectionPolicyIndex(nodes, ancestor.segments)
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) clearSubtree(scope *collectionPolicyScope, node *collectionPolicyRetirementIndex) {
|
||||
if node.hasEntry {
|
||||
if entry, found := scope.collections[node.entryKey]; found {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
scope.collections[node.entryKey] = entry
|
||||
}
|
||||
node.entryKey = ""
|
||||
node.hasEntry = false
|
||||
}
|
||||
for segment, child := range node.children {
|
||||
index.clearSubtree(scope, child)
|
||||
if child.empty() {
|
||||
delete(node.children, segment)
|
||||
}
|
||||
}
|
||||
if len(node.children) == 0 {
|
||||
node.children = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) empty() bool {
|
||||
return index == nil || !index.hasEntry && len(index.children) == 0
|
||||
}
|
||||
|
||||
func pruneCollectionPolicyIndex(nodes []*collectionPolicyRetirementIndex, segments []pathSegment) {
|
||||
for position := len(segments) - 1; position >= 0; position-- {
|
||||
child := nodes[position+1]
|
||||
if !child.empty() {
|
||||
return
|
||||
}
|
||||
delete(nodes[position].children, segments[position])
|
||||
}
|
||||
}
|
||||
|
||||
func collectionPolicyHasRetirementState(policy collectionPolicy) bool {
|
||||
return policy.identityFields != nil || policy.identityPresent || policy.identityLocation != nil ||
|
||||
policy.recordOrder != nil || policy.recordPolicies != nil
|
||||
}
|
||||
|
||||
func (assembly *assemblyAccumulator) setCollectionPolicy(destination Path, policy collectionPolicy) {
|
||||
if assembly == nil || assembly.current == nil {
|
||||
return
|
||||
}
|
||||
if assembly.collectionPolicyRetirement == nil {
|
||||
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||
}
|
||||
assembly.current.collectionPolicies.set(destination, policy)
|
||||
entry := assembly.current.collectionPolicies.collections[destination.String()]
|
||||
if collectionPolicyHasRetirementState(entry.policy) {
|
||||
assembly.collectionPolicyRetirement.set(entry)
|
||||
} else {
|
||||
assembly.collectionPolicyRetirement.remove(destination)
|
||||
}
|
||||
}
|
||||
|
||||
func (assembly *assemblyAccumulator) retireCollectionPoliciesAtOrBelow(ancestor Path) {
|
||||
if assembly == nil || assembly.current == nil {
|
||||
return
|
||||
}
|
||||
if assembly.collectionPolicyRetirement == nil {
|
||||
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||
}
|
||||
assembly.collectionPolicyRetirement.retire(&assembly.current.collectionPolicies, ancestor)
|
||||
}
|
||||
@@ -21,8 +21,8 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if !resolved.declarationsPresent || !resolved.identityDeclarationPresent || !resolved.modeDeclarationPresent {
|
||||
t.Fatalf("declaration presence was lost: %#v", resolved)
|
||||
if !resolved.modeDeclarationPresent {
|
||||
t.Fatalf("mode declaration was lost: %#v", resolved)
|
||||
}
|
||||
wantFields := []string{"id", "namespace"}
|
||||
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
|
||||
@@ -31,9 +31,6 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
||||
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
|
||||
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
|
||||
}
|
||||
if len(resolved.declarations) != 3 || resolved.declarations[0].identityLocation == nil || resolved.declarations[2].location.Line == 0 {
|
||||
t.Fatalf("declaration locations were not retained: %#v", resolved.declarations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,23 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
// operation at that exact path.
|
||||
type collectionReplacementPaths map[string]bool
|
||||
|
||||
type lowerCollectionPolicyView struct {
|
||||
scope collectionPolicyScope
|
||||
replacements collectionReplacementPaths
|
||||
}
|
||||
|
||||
func (view lowerCollectionPolicyView) at(path Path) (collectionPolicy, bool) {
|
||||
entry, found := view.scope.collections[path.String()]
|
||||
if !found {
|
||||
return collectionPolicy{}, false
|
||||
}
|
||||
policy := entry.policy
|
||||
if view.replacements.contains(path, true) {
|
||||
clearCollectionIdentity(&policy)
|
||||
}
|
||||
return policy, true
|
||||
}
|
||||
|
||||
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
|
||||
for length := 0; length <= len(path.segments); length++ {
|
||||
prefix := Path{segments: path.segments[:length]}
|
||||
@@ -27,6 +44,26 @@ func lowerCollectionPoliciesForClaims(
|
||||
declarations map[string][]sequenceDeclarationSpec,
|
||||
application sequencePolicy,
|
||||
) (collectionPolicyScope, collectionReplacementPaths) {
|
||||
replaced := collectionReplacementPathsForClaims(lower, claims, declarations, application)
|
||||
// This materialization is used when inheritance creates an independently
|
||||
// owned local scope. Strict tier resolution uses lowerCollectionPolicyView
|
||||
// instead and keeps only its sparse current-scope result.
|
||||
result := cloneCollectionPolicyScope(lower)
|
||||
for key, entry := range result.collections {
|
||||
if replaced.contains(entry.destination, true) {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
result.collections[key] = entry
|
||||
}
|
||||
}
|
||||
return result, replaced
|
||||
}
|
||||
|
||||
func collectionReplacementPathsForClaims(
|
||||
lower collectionPolicyScope,
|
||||
claims []compositionClaim,
|
||||
declarations map[string][]sequenceDeclarationSpec,
|
||||
application sequencePolicy,
|
||||
) collectionReplacementPaths {
|
||||
byPath := make(map[string]compositionClaim, len(claims))
|
||||
rootPath := ""
|
||||
if len(claims) != 0 {
|
||||
@@ -62,14 +99,7 @@ func lowerCollectionPoliciesForClaims(
|
||||
replaced[key] = false
|
||||
}
|
||||
}
|
||||
result := cloneCollectionPolicyScope(lower)
|
||||
for key, entry := range result.collections {
|
||||
if replaced.contains(entry.destination, true) {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
result.collections[key] = entry
|
||||
}
|
||||
}
|
||||
return result, replaced
|
||||
return replaced
|
||||
}
|
||||
|
||||
func clearCollectionIdentity(policy *collectionPolicy) {
|
||||
|
||||
@@ -2,7 +2,10 @@ package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
|
||||
@@ -109,3 +112,239 @@ func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblyPolicyRetirementUsesSemanticPathSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
var policies collectionPolicyScope
|
||||
slashPath := Keys("branch/name", "items")
|
||||
separateKeysPath := Keys("branch", "name", "items")
|
||||
numericKeyPath := Keys("items", "0", "named")
|
||||
sequenceIndexPath := Keys("items").Index(0).Key("indexed")
|
||||
policies.set(slashPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(separateKeysPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(numericKeyPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(sequenceIndexPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
|
||||
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("branch/name"))
|
||||
assertRetirement := func(path Path, identityPresent bool) {
|
||||
t.Helper()
|
||||
policy, found := assembly.current.collectionPolicies.at(path)
|
||||
if !found || policy.identityPresent != identityPresent {
|
||||
t.Fatalf("policy at %s = %#v (found %t), want identity present %t", path, policy, found, identityPresent)
|
||||
}
|
||||
}
|
||||
assertRetirement(slashPath, false)
|
||||
assertRetirement(separateKeysPath, true)
|
||||
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("items", "0"))
|
||||
assertRetirement(numericKeyPath, false)
|
||||
assertRetirement(sequenceIndexPath, true)
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("items").Index(0))
|
||||
assertRetirement(sequenceIndexPath, false)
|
||||
}
|
||||
|
||||
func TestAssemblyPolicyRetirementPrunesAndReindexesAroundModeOnlyEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
identityPath := Keys("catalog", "items")
|
||||
modePath := Keys("catalog", "settings")
|
||||
var policies collectionPolicyScope
|
||||
policies.set(identityPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(modePath, collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
|
||||
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
|
||||
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
retired, found := assembly.current.collectionPolicies.at(identityPath)
|
||||
if !found || retired.identityPresent || !assembly.collectionPolicyRetirement.empty() {
|
||||
t.Fatalf("first retirement left identity or stale index: %#v, index %#v", retired, assembly.collectionPolicyRetirement)
|
||||
}
|
||||
mode, found := assembly.current.collectionPolicies.at(modePath)
|
||||
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
|
||||
t.Fatalf("mode-only policy changed during retirement: %#v, found %t", mode, found)
|
||||
}
|
||||
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
newPath := Keys("catalog", "items", "nested")
|
||||
assembly.setCollectionPolicy(newPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
|
||||
if assembly.collectionPolicyRetirement.empty() {
|
||||
t.Fatal("new identity-bearing policy was not reindexed")
|
||||
}
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
newPolicy, found := assembly.current.collectionPolicies.at(newPath)
|
||||
if !found || newPolicy.identityPresent || !assembly.collectionPolicyRetirement.empty() {
|
||||
t.Fatalf("repeated ancestor retirement left new identity or stale index: %#v, index %#v", newPolicy, assembly.collectionPolicyRetirement)
|
||||
}
|
||||
mode, found = assembly.current.collectionPolicies.at(modePath)
|
||||
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
|
||||
t.Fatalf("repeated retirement changed retained mode: %#v, found %t", mode, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisjointScalarReplacementsRetainIndexedCollectionPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
const policyCount = 128
|
||||
const replacementCount = 32
|
||||
|
||||
var policies collectionPolicyScope
|
||||
collectionEntries := make([]document.Entry, 0, policyCount)
|
||||
for index := 0; index < policyCount; index++ {
|
||||
key := fmt.Sprintf("collection-%03d", index)
|
||||
path := Keys("collections", key)
|
||||
policies.set(path, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
collectionEntries = append(collectionEntries, document.Entry{
|
||||
Key: key, Value: &document.Node{Kind: document.Sequence},
|
||||
})
|
||||
}
|
||||
|
||||
baseScalars := make([]document.Entry, 0, replacementCount)
|
||||
incomingScalars := make([]document.Entry, 0, replacementCount)
|
||||
for index := 0; index < replacementCount; index++ {
|
||||
key := fmt.Sprintf("scalar-%03d", index)
|
||||
baseScalars = append(baseScalars, document.Entry{
|
||||
Key: key, Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "previous", Value: &document.Node{Kind: document.Boolean, Text: "true"},
|
||||
}}},
|
||||
})
|
||||
incomingScalars = append(incomingScalars, document.Entry{
|
||||
Key: key, Value: &document.Node{Kind: document.String, Text: "replaced"},
|
||||
})
|
||||
}
|
||||
base := &composedDocument{
|
||||
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||
{Key: "collections", Value: &document.Node{Kind: document.Mapping, Mapping: collectionEntries}},
|
||||
{Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: baseScalars}},
|
||||
}},
|
||||
collectionPolicies: policies,
|
||||
origins: make(map[string]Origin),
|
||||
}
|
||||
incoming := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: incomingScalars},
|
||||
}}}
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
incoming, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, nil,
|
||||
); len(diagnostics) != 0 {
|
||||
t.Fatalf("apply disjoint scalar replacements: %#v", diagnostics)
|
||||
}
|
||||
for index := 0; index < policyCount; index++ {
|
||||
path := Keys("collections", fmt.Sprintf("collection-%03d", index))
|
||||
policy, found := assembly.current.collectionPolicies.at(path)
|
||||
if !found || !policy.identityPresent || len(policy.identityFields) != 1 || policy.identityFields[0] != "id" {
|
||||
t.Fatalf("disjoint replacement changed policy %s: %#v, found %t", path, policy, found)
|
||||
}
|
||||
}
|
||||
for index := 0; index < replacementCount; index++ {
|
||||
path := Keys("scalars", fmt.Sprintf("scalar-%03d", index))
|
||||
value, found := documentAtPath(assembly.current.root, path)
|
||||
if !found || value.Kind != document.String || value.Text != "replaced" {
|
||||
t.Fatalf("scalar replacement at %s = %#v, found %t", path, value, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisjointTierOverlaysRetainNestedCollectionPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nestedOwner := newCollectionRecordPolicy()
|
||||
nestedOwner.scope.set(Keys("prompts"), collectionPolicy{
|
||||
identityFields: []string{"name"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
itemsPolicy := collectionPolicyWithRecords(collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
}, nestedOwner)
|
||||
var policies collectionPolicyScope
|
||||
policies.set(Keys("items"), itemsPolicy)
|
||||
policies.set(Keys("keyOnly"), collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
})
|
||||
policies.set(Keys("modeOnly"), collectionPolicy{
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
|
||||
base := &composedDocument{
|
||||
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||
{Key: "items", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
|
||||
Kind: document.Mapping,
|
||||
Mapping: []document.Entry{
|
||||
{Key: "id", Value: &document.Node{Kind: document.String, Text: "one"}},
|
||||
{Key: "prompts", Value: &document.Node{Kind: document.Sequence}},
|
||||
},
|
||||
}}}},
|
||||
{Key: "keyOnly", Value: &document.Node{Kind: document.Sequence}},
|
||||
{Key: "modeOnly", Value: &document.Node{Kind: document.Sequence}},
|
||||
}},
|
||||
collectionPolicies: policies,
|
||||
origins: make(map[string]Origin),
|
||||
}
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
initialItems, found := assembly.current.collectionPolicies.at(Keys("items"))
|
||||
if !found {
|
||||
t.Fatal("initial item policy is missing")
|
||||
}
|
||||
initialRecord, found := initialItems.recordPolicyAt(0)
|
||||
if !found {
|
||||
t.Fatal("initial nested record owner is missing")
|
||||
}
|
||||
|
||||
for index, key := range []string{"firstTier", "secondTier", "thirdTier"} {
|
||||
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: key, Value: &document.Node{Kind: document.Boolean, Text: "true"},
|
||||
}}}
|
||||
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
|
||||
); len(diagnostics) != 0 {
|
||||
t.Fatalf("apply disjoint tier %d: %#v", index, diagnostics)
|
||||
}
|
||||
|
||||
retainedItems, found := assembly.current.collectionPolicies.at(Keys("items"))
|
||||
if !found || !retainedItems.identityPresent || len(retainedItems.identityFields) != 1 || retainedItems.identityFields[0] != "id" ||
|
||||
!retainedItems.explicitModePresent || retainedItems.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("tier %d changed retained item policy: %#v, found %t", index, retainedItems, found)
|
||||
}
|
||||
retainedRecord, found := retainedItems.recordPolicyAt(0)
|
||||
if !found || retainedRecord.owner != initialRecord.owner {
|
||||
t.Fatalf("tier %d replaced the nested record owner: %#v, found %t", index, retainedRecord, found)
|
||||
}
|
||||
nested, found := retainedRecord.scope.at(Keys("prompts"))
|
||||
if !found || !nested.identityPresent || len(nested.identityFields) != 1 || nested.identityFields[0] != "name" ||
|
||||
!nested.explicitModePresent || nested.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("tier %d changed nested collection policy: %#v, found %t", index, nested, found)
|
||||
}
|
||||
keyOnly, found := assembly.current.collectionPolicies.at(Keys("keyOnly"))
|
||||
if !found || !keyOnly.identityPresent || keyOnly.explicitModePresent {
|
||||
t.Fatalf("tier %d changed key-only policy: %#v, found %t", index, keyOnly, found)
|
||||
}
|
||||
modeOnly, found := assembly.current.collectionPolicies.at(Keys("modeOnly"))
|
||||
if !found || modeOnly.identityPresent || !modeOnly.explicitModePresent || modeOnly.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("tier %d changed mode-only policy: %#v, found %t", index, modeOnly, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayNonSequenceClaimAtRetainedCollectionStillFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var policies collectionPolicyScope
|
||||
policies.set(Keys("items"), collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
base := &composedDocument{
|
||||
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "items", Value: &document.Node{Kind: document.Sequence},
|
||||
}}},
|
||||
collectionPolicies: policies,
|
||||
origins: make(map[string]Origin),
|
||||
}
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "items", Value: &document.Node{Kind: document.String, Text: "not a sequence"},
|
||||
}}}
|
||||
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
|
||||
); len(diagnostics) == 0 {
|
||||
t.Fatal("non-sequence overlay at an append collection unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,20 @@ type sequenceHistoryContribution struct {
|
||||
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
||||
// history applications are separate from its root and remain unchanged.
|
||||
type assemblyAccumulator struct {
|
||||
current *composedDocument
|
||||
index mappingIndex
|
||||
current *composedDocument
|
||||
index mappingIndex
|
||||
collectionPolicyRetirement *collectionPolicyRetirementIndex
|
||||
}
|
||||
|
||||
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
||||
return &assemblyAccumulator{current: owned}
|
||||
policies := collectionPolicyScope{}
|
||||
if owned != nil {
|
||||
policies = owned.collectionPolicies
|
||||
}
|
||||
return &assemblyAccumulator{
|
||||
current: owned,
|
||||
collectionPolicyRetirement: newCollectionPolicyRetirementIndex(policies),
|
||||
}
|
||||
}
|
||||
|
||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||
@@ -215,7 +223,7 @@ func composeDocumentsWithCollectionPolicy(
|
||||
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
|
||||
}
|
||||
for sourceIndex, input := range inputs {
|
||||
application := historyApplication{layer: LayerBase, value: input}
|
||||
application := historyApplication{layer: LayerMain, value: input}
|
||||
for _, assigned := range rangesBySource[sourceIndex] {
|
||||
pointer := assigned.path.String()
|
||||
mode := sequenceHistoryAppend
|
||||
@@ -247,7 +255,7 @@ func composeDocumentsWithCollectionPolicy(
|
||||
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
|
||||
pointer := path.String()
|
||||
if _, present := origins[pointer]; !present {
|
||||
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}}
|
||||
origins[pointer] = Origin{Layer: LayerMain, Locations: []Location{publicLocation(node.Location)}}
|
||||
}
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
@@ -305,14 +313,10 @@ func resolveStrictCollectionPolicies(
|
||||
valuePresent := make(map[string]bool)
|
||||
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
|
||||
for _, claim := range claims {
|
||||
if len(lower.collections) != 0 {
|
||||
valuePresent[claim.path.String()] = true
|
||||
}
|
||||
if claim.kind != document.Sequence {
|
||||
continue
|
||||
}
|
||||
key := claim.path.String()
|
||||
if !seen[key] {
|
||||
valuePresent[key] = true
|
||||
_, hasLower := lower.collections[key]
|
||||
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
|
||||
seen[key] = true
|
||||
paths = append(paths, claim.path.clone())
|
||||
}
|
||||
@@ -327,21 +331,18 @@ func resolveStrictCollectionPolicies(
|
||||
}
|
||||
|
||||
identityFieldsByPath := make(map[string][]string)
|
||||
effectiveLower := lower
|
||||
if len(lower.collections) != 0 {
|
||||
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||
}
|
||||
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
|
||||
replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||
lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
|
||||
var collectionPolicies collectionPolicyScope
|
||||
var diagnostics []Diagnostic
|
||||
for _, path := range paths {
|
||||
key := path.String()
|
||||
currentDeclarations := declarationsByPath[key]
|
||||
|
||||
lowerEntry, hasLower := effectiveLower.collections[key]
|
||||
lowerPolicyValue, hasLower := lowerView.at(path)
|
||||
var lowerPolicy *collectionPolicy
|
||||
if hasLower {
|
||||
copy := cloneCollectionPolicy(lowerEntry.policy)
|
||||
lowerPolicy = ©
|
||||
lowerPolicy = &lowerPolicyValue
|
||||
}
|
||||
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
|
||||
|
||||
@@ -358,7 +359,7 @@ func resolveStrictCollectionPolicies(
|
||||
if hasLower && !valuePresent[key] {
|
||||
// A declaration without a new value changes policy metadata but
|
||||
// leaves retained record ownership untouched.
|
||||
policyForPath = cloneCollectionPolicy(lowerEntry.policy)
|
||||
policyForPath = cloneCollectionPolicy(lowerPolicyValue)
|
||||
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
|
||||
policyForPath.identityPresent = resolution.policy.identityPresent
|
||||
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
|
||||
@@ -367,7 +368,7 @@ func resolveStrictCollectionPolicies(
|
||||
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
|
||||
}
|
||||
if collectionPolicies.collections == nil {
|
||||
collectionPolicies.collections = make(map[string]collectionPolicyEntry)
|
||||
collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
|
||||
}
|
||||
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
|
||||
}
|
||||
@@ -483,21 +484,6 @@ func allMappingClaims(claims []compositionClaim) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
|
||||
for _, incomingEntry := range incoming.Mapping {
|
||||
position := index.find(target, incomingEntry.Key)
|
||||
if position < 0 {
|
||||
cloned := incomingEntry
|
||||
cloned.Value = document.Clone(incomingEntry.Value)
|
||||
index.append(target, cloned)
|
||||
continue
|
||||
}
|
||||
// Claim validation guarantees that the only remaining overlap is a
|
||||
// mapping pair. Empty mappings naturally contribute no child entries.
|
||||
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
|
||||
}
|
||||
}
|
||||
|
||||
func documentEntryIndex(entries []document.Entry, key string) int {
|
||||
for index := range entries {
|
||||
if entries[index].Key == key {
|
||||
@@ -527,7 +513,7 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
|
||||
walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
|
||||
pointer := effective.String()
|
||||
origin := origins[pointer]
|
||||
origin.Layer = LayerBase
|
||||
origin.Layer = LayerMain
|
||||
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
||||
origins[pointer] = origin
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ values: [one, two]
|
||||
{SourceID: "source-1", Line: 2, Column: 3},
|
||||
{SourceID: "source-2", Line: 2, Column: 3},
|
||||
}
|
||||
if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerBase, Locations: wantLocations}) {
|
||||
if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerMain, Locations: wantLocations}) {
|
||||
t.Fatalf("services origin = %#v, want composite origin", serviceOrigin)
|
||||
}
|
||||
wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"}
|
||||
@@ -489,6 +489,96 @@ func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsReturnsSparseCollectionPolicies(t *testing.T) {
|
||||
itemsPath := Keys("items")
|
||||
unrelatedPath := Keys("unrelated")
|
||||
var lower collectionPolicyScope
|
||||
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
unrelated := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||
unrelated.setRecordPolicy(newCollectionRecordPolicy())
|
||||
lower.set(unrelatedPath, unrelated)
|
||||
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, nil,
|
||||
parseCompositionDocument(t, "incoming", "items: [{id: current}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose incoming sequence: %#v", diagnostics)
|
||||
}
|
||||
if len(composed.collectionPolicies.collections) != 1 {
|
||||
t.Fatalf("incoming collection policies = %#v; want only touched destinations", composed.collectionPolicies.collections)
|
||||
}
|
||||
if policy, found := composed.collectionPolicies.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("resolved incoming policy = %#v, found %t", policy, found)
|
||||
}
|
||||
if _, found := composed.collectionPolicies.at(unrelatedPath); found {
|
||||
t.Fatal("incoming composition copied an unrelated lower policy")
|
||||
}
|
||||
if policy, found := lower.at(unrelatedPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"name"}) || len(policy.recordOrder) != 1 {
|
||||
t.Fatalf("borrowed lower policy changed during composition: %#v, found %t", policy, found)
|
||||
}
|
||||
|
||||
composed.collectionPolicies.collections[itemsPath.String()].policy.identityFields[0] = "changed"
|
||||
if policy, _ := lower.at(itemsPath); !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("incoming policy shares identity storage with lower scope: %#v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictCompositionMasksLowerIdentityForReplacedAncestor(t *testing.T) {
|
||||
itemsPath := Keys("catalog", "items")
|
||||
var lower collectionPolicyScope
|
||||
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
declaration := sequenceDeclarationSpec{destination: Keys("catalog"), mode: sequenceModeReplace, modePresent: true}
|
||||
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||
parseCompositionDocument(t, "source-1", "catalog: {items: [{name: first}]}\n"),
|
||||
parseCompositionDocument(t, "source-2", "catalog: {items: [{name: second}]}\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/catalog/items" {
|
||||
t.Fatalf("strict peers used identity masked by replaced ancestor: %#v", diagnostics)
|
||||
}
|
||||
if policy, found := lower.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("strict resolution mutated borrowed lower identity: %#v, found %t", policy, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsDeclarationWithoutValueRetainsOwnedRecordPolicy(t *testing.T) {
|
||||
path := Keys("items")
|
||||
nestedPath := Keys("prompts")
|
||||
var nested collectionPolicyScope
|
||||
nested.set(nestedPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
|
||||
record := collectionRecordPolicy{owner: newCollectionRecordPolicy().owner, scope: nested}
|
||||
lowerPolicy := collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
}
|
||||
lowerPolicy.setRecordPolicy(record)
|
||||
var lower collectionPolicyScope
|
||||
lower.set(path, lowerPolicy)
|
||||
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
|
||||
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||
parseCompositionDocument(t, "metadata", "other: value\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose declaration without a value: %#v", diagnostics)
|
||||
}
|
||||
resolved, found := composed.collectionPolicies.at(path)
|
||||
if !found || !resolved.identityPresent || !reflect.DeepEqual(resolved.identityFields, []string{"id"}) || resolved.explicitMode != sequenceModeReplace || !resolved.explicitModePresent {
|
||||
t.Fatalf("declaration-only result changed retained collection policy: %#v, found %t", resolved, found)
|
||||
}
|
||||
if len(resolved.recordOrder) != 1 || len(resolved.recordPolicies) != 1 {
|
||||
t.Fatalf("declaration-only result lost record ownership: %#v", resolved)
|
||||
}
|
||||
owner := resolved.recordOrder[0]
|
||||
resolved.recordPolicies[owner].collections[nestedPath.String()].policy.identityFields[0] = "changed"
|
||||
retained, _ := lower.at(path)
|
||||
if nestedPolicy, found := retained.recordPolicies[owner].at(nestedPath); !found || !reflect.DeepEqual(nestedPolicy.identityFields, []string{"name"}) {
|
||||
t.Fatalf("declaration-only result borrowed nested record policy: %#v, found %t", nestedPolicy, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
|
||||
path := Keys("records")
|
||||
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
|
||||
|
||||
@@ -43,9 +43,10 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
||||
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||
}
|
||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("composition result aliases an input document")
|
||||
if mutateExistingDocumentDescendant(composed.root) {
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("composition result aliases an input document")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +61,41 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay mutated an input document")
|
||||
}
|
||||
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay result aliases an input document")
|
||||
if mutateExistingDocumentDescendant(overlaid.root) {
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay result aliases an input document")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mutateExistingDocumentDescendant(node *document.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
// Empty containers have no existing element for the alias probe.
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
if len(node.Mapping) == 0 {
|
||||
return false
|
||||
}
|
||||
entry := &node.Mapping[0]
|
||||
if mutateExistingDocumentDescendant(entry.Value) {
|
||||
return true
|
||||
}
|
||||
entry.Key += "-fuzz-detachment"
|
||||
return true
|
||||
case document.Sequence:
|
||||
if len(node.Sequence) == 0 {
|
||||
return false
|
||||
}
|
||||
if mutateExistingDocumentDescendant(node.Sequence[0]) {
|
||||
return true
|
||||
}
|
||||
node.Sequence[0].Text += "fuzz-detachment"
|
||||
return true
|
||||
default:
|
||||
node.Text += "fuzz-detachment"
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ type decodeIssue struct {
|
||||
}
|
||||
|
||||
type inputPresence struct {
|
||||
null bool
|
||||
location document.Location
|
||||
}
|
||||
|
||||
@@ -91,19 +90,30 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
}
|
||||
}
|
||||
if descriptor.kind == descriptorPointer {
|
||||
var elementBase reflect.Value
|
||||
if base.IsNil() {
|
||||
elementBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||
} else {
|
||||
elementBase = base.Elem()
|
||||
terminal, ok := terminalDescriptor(descriptor)
|
||||
if !ok {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration type cannot represent a non-null value")}
|
||||
}
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||
var pointers []*typeDescriptor
|
||||
currentBase := base
|
||||
for current := descriptor; current != terminal; current = current.element {
|
||||
pointers = append(pointers, current)
|
||||
if currentBase.IsNil() {
|
||||
currentBase = reflect.Zero(current.typeOf.Elem())
|
||||
} else {
|
||||
currentBase = currentBase.Elem()
|
||||
}
|
||||
}
|
||||
decoded, issues := decodeValue(node, currentBase, terminal, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf.Elem())
|
||||
result.Elem().Set(decoded)
|
||||
return result, nil
|
||||
for i := len(pointers) - 1; i >= 0; i-- {
|
||||
result := reflect.New(pointers[i].typeOf.Elem())
|
||||
result.Elem().Set(decoded)
|
||||
decoded = result
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
switch descriptor.kind {
|
||||
@@ -392,10 +402,11 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
||||
}
|
||||
|
||||
func acceptsMapping(descriptor *typeDescriptor) bool {
|
||||
for descriptor != nil && descriptor.kind == descriptorPointer {
|
||||
descriptor = descriptor.element
|
||||
terminal, ok := terminalDescriptor(descriptor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic)
|
||||
return terminal.kind == descriptorStruct || terminal.kind == descriptorMap || terminal.kind == descriptorDynamic
|
||||
}
|
||||
|
||||
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
|
||||
@@ -422,7 +433,7 @@ func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Val
|
||||
}
|
||||
|
||||
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
|
||||
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location}
|
||||
presence[path.String()] = inputPresence{location: node.Location}
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
@@ -437,7 +448,7 @@ func collectPresence(node *document.Node, path Path, presence map[string]inputPr
|
||||
|
||||
func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
|
||||
if input, ok := presence[path.String()]; ok {
|
||||
origins[path.String()] = Origin{Layer: LayerBase, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
|
||||
origins[path.String()] = Origin{Layer: LayerMain, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
|
||||
} else {
|
||||
origins[path.String()] = Origin{Layer: LayerDefault}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ custom: accepted
|
||||
if _, ok := decoded.presence[Keys("nested", "a").String()]; ok {
|
||||
t.Fatal("parent mapping made omitted child explicit")
|
||||
}
|
||||
if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerBase {
|
||||
if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerMain {
|
||||
t.Fatalf("origins = %#v", decoded.origins)
|
||||
}
|
||||
if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 {
|
||||
|
||||
@@ -11,33 +11,13 @@ var (
|
||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||
)
|
||||
|
||||
type placementMode uint8
|
||||
type PlacementMode uint8
|
||||
|
||||
const (
|
||||
placementModeMapping placementMode = iota
|
||||
placementModeSequenceItem
|
||||
PlacementModeMapping PlacementMode = iota
|
||||
PlacementModeSequenceItem
|
||||
)
|
||||
|
||||
// PlacementModeFromFacade preserves root-owned numeric values until preflight
|
||||
// can reject unsupported modes.
|
||||
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
|
||||
|
||||
// PlacementModeForFacade converts private source evidence to the root-owned mode.
|
||||
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
|
||||
|
||||
// InputWithPlacement copies the root-owned mode into a private engine input.
|
||||
func InputWithPlacement(input Input, value uint8) Input {
|
||||
input.placementMode = placementMode(value)
|
||||
return input
|
||||
}
|
||||
|
||||
// OptionsWithPlacement copies root-owned controls into private engine options.
|
||||
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
|
||||
options.destination = destination.clone()
|
||||
options.placementMode = placementMode(value)
|
||||
return options
|
||||
}
|
||||
|
||||
func normalizeDestination(path Path) (Path, error) {
|
||||
if !path.valid() {
|
||||
return Path{}, errInvalidDestination
|
||||
@@ -74,26 +54,26 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
// 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) {
|
||||
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||
return placeDocumentWithMode(body, destination, PlacementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
// placeDocumentWithMode places a source mapping beneath its literal destination.
|
||||
// Sequence-item placement adds one sequence containing the complete mapping.
|
||||
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
func placeDocumentWithMode(body *document.Node, destination Path, mode PlacementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
normalized, err := normalizeDestination(destination)
|
||||
if err != nil {
|
||||
return Path{}, nil, err
|
||||
}
|
||||
if mode != placementModeMapping && mode != placementModeSequenceItem {
|
||||
if mode != PlacementModeMapping && mode != PlacementModeSequenceItem {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if mode == placementModeSequenceItem && len(normalized.segments) == 0 {
|
||||
if mode == PlacementModeSequenceItem && len(normalized.segments) == 0 {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if body == nil {
|
||||
return normalized, nil, nil
|
||||
}
|
||||
if mode == placementModeSequenceItem && body.Kind != document.Mapping {
|
||||
if mode == PlacementModeSequenceItem && body.Kind != document.Mapping {
|
||||
return normalized, nil, errInvalidDestination
|
||||
}
|
||||
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
|
||||
@@ -101,7 +81,7 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
|
||||
}
|
||||
|
||||
placed := body
|
||||
if mode == placementModeSequenceItem {
|
||||
if mode == PlacementModeSequenceItem {
|
||||
placed = &document.Node{
|
||||
Kind: document.Sequence, Location: sourceRoot,
|
||||
Sequence: []*document.Node{placed},
|
||||
@@ -125,20 +105,20 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
|
||||
|
||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
|
||||
return destinationWithinLimitsWithMode(body, destination, PlacementModeMapping, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
|
||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode PlacementMode, maxDepth, maxNodes int) bool {
|
||||
if body == nil {
|
||||
return true
|
||||
}
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != PlacementModeMapping && mode != PlacementModeSequenceItem) {
|
||||
return false
|
||||
}
|
||||
segments := len(destination.segments)
|
||||
sequenceNodes := 0
|
||||
sequenceDepth := 0
|
||||
if mode == placementModeSequenceItem {
|
||||
if mode == PlacementModeSequenceItem {
|
||||
if segments == 0 {
|
||||
return false
|
||||
}
|
||||
@@ -160,9 +140,9 @@ func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode
|
||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int {
|
||||
func destinationWrapperNodeCountForMode(destination Path, mode PlacementMode) int {
|
||||
count := destinationWrapperNodeCount(destination)
|
||||
if mode == placementModeSequenceItem {
|
||||
if mode == PlacementModeSequenceItem {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
|
||||
@@ -34,17 +34,17 @@ func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: empty, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("over-budget placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
|
||||
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
|
||||
if acquired == nil || len(diagnostics) != 0 || acquired.inputNodes != test.wantCharge {
|
||||
t.Fatalf("bounded placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -62,9 +62,9 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
||||
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||
charge := len(inputs) * perInput
|
||||
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || acquired == nil || accounting.nodes != limits.nodes || acquired.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", acquired, diagnostics, accounting, charge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,9 @@ id: parent
|
||||
childDestination := appendDestination(destination, Keys("children"))
|
||||
otherDestination := Keys("other")
|
||||
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
||||
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
|
||||
destinationWrapperNodeCountForMode(destination, PlacementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(childDestination, PlacementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(otherDestination, PlacementModeSequenceItem)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
@@ -107,22 +107,22 @@ id: parent
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: destination, placementMode: PlacementModeSequenceItem,
|
||||
}, {
|
||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
|
||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: PlacementModeSequenceItem,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("under-budget nested item glob = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
|
||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||
if acquired == nil || len(diagnostics) != 0 || len(acquired.sources) != 3 {
|
||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", acquired, acquired.sources, diagnostics)
|
||||
}
|
||||
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||
if acquired.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", acquired.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
composition, sources, _, diagnostics := testComposeUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: mainSource, imports: true, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
@@ -217,22 +217,26 @@ func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
|
||||
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
|
||||
if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound relative placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", acquired, 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 acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("relative placement node charge = %d/%d, want %d", acquired.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())
|
||||
}
|
||||
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose relative placement: %#v", diagnostics)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -279,24 +283,28 @@ func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
|
||||
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
|
||||
if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound glob placement = %#v, %#v", acquired, 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 len(diagnostics) != 0 || acquired == nil || len(sources) != len(fragmentPaths)+1 {
|
||||
t.Fatalf("bounded glob placement = %#v, %#v, %#v", acquired, 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)
|
||||
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("glob relative placement node charge = %d/%d, want %d", acquired.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())
|
||||
}
|
||||
}
|
||||
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose glob placement: %#v", diagnostics)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -365,13 +373,17 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
||||
limits := defaultImportLimits()
|
||||
accounting := &importAccounting{}
|
||||
request := testImportRequest(File(mainPath), nil, main, destination, nil, nil)
|
||||
composed, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
||||
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(sources) != 1 {
|
||||
t.Fatalf("absent optional import = %#v, %#v, %#v", acquired, 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 acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("absent optional node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose absent optional import: %#v", diagnostics)
|
||||
}
|
||||
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)
|
||||
@@ -384,12 +396,12 @@ func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounting := &importAccounting{}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
{source: NoSource(), destination: Keys("absent")},
|
||||
{source: Mapping("root", map[string]any{})},
|
||||
}}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
|
||||
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(acquired.sources) != 1 || accounting.nodes != 1 || acquired.inputNodes != 1 {
|
||||
t.Fatalf("root and absent placement = %#v, diagnostics %#v, accounting %#v", acquired, diagnostics, accounting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +458,7 @@ func testAssembleLayeredInput[T any](request loadRequest[T], input assembledInpu
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
policy := acquisitionPolicy{
|
||||
importDefaults: snapshotImportDefaults(request.importDefaults),
|
||||
importDefaults: request.importDefaults,
|
||||
sequencePolicy: request.sequencePolicy,
|
||||
importLimits: defaultImportLimits(),
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T)
|
||||
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"))
|
||||
resolution.RecordNormalization(Keys("catalog", "entries", "child", "derived"), "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) {
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
||||
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
|
||||
}
|
||||
item := func(label string, value map[string]any) Input {
|
||||
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}
|
||||
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem}
|
||||
}
|
||||
options := func(inputs ...Input) LayeredOptions[map[string]any] {
|
||||
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
|
||||
@@ -287,7 +287,7 @@ func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testi
|
||||
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
|
||||
observed.resolve++
|
||||
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic"))
|
||||
resolution.RecordNormalization(Keys("tenant", "derived"), "derive from mounted input", Keys("tenant", "dynamic"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
|
||||
@@ -425,7 +425,7 @@ func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
|
||||
definition: Definition[config]{
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic"))
|
||||
resolution.RecordNormalization(Keys("tenant", "derived"), "derive from dynamic input", Keys("tenant", "dynamic"))
|
||||
return nil
|
||||
},
|
||||
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },
|
||||
|
||||
@@ -22,11 +22,11 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
|
||||
placementMode: placementModeSequenceItem,
|
||||
PlacementMode: PlacementModeSequenceItem,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -40,7 +40,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved {
|
||||
if len(sources) != 1 || sources[0].placementMode != PlacementModeSequenceItem || sources[0].destinationUnresolved {
|
||||
t.Fatalf("source placement evidence = %#v", sources)
|
||||
}
|
||||
if got := sources[0].Destination.String(); got != "/catalog/entries" {
|
||||
@@ -48,7 +48,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
}
|
||||
|
||||
absent, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -64,17 +64,17 @@ func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode placementMode
|
||||
mode PlacementMode
|
||||
path Path
|
||||
}{
|
||||
{name: "root item", mode: placementModeSequenceItem},
|
||||
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")},
|
||||
{name: "root item", mode: PlacementModeSequenceItem},
|
||||
{name: "unknown mode", mode: PlacementMode(255), path: Keys("catalog")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reader := bytes.NewBufferString("value: unused\n")
|
||||
_, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode},
|
||||
Input{Source: Reader("reader", reader), Destination: test.path, PlacementMode: test.mode},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err == nil || reader.Len() == 0 {
|
||||
@@ -93,7 +93,7 @@ func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
|
||||
|
||||
loaded, err := Load[destinationItemConfig](
|
||||
Mapping("ordinary", map[string]any{}), testSecretBinding(),
|
||||
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
Options[destinationItemConfig]{Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
|
||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, PlacementModeMapping, body.Location, 5, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
root := document.Location{SourceID: "item", Line: 1, Column: 1}
|
||||
body := &document.Node{Kind: document.Mapping, Location: root}
|
||||
destination := Keys("catalog", "entries")
|
||||
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6)
|
||||
placedPath, placed, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, 3, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -253,13 +253,13 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
|
||||
}
|
||||
|
||||
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
if _, _, err := placeDocumentWithMode(body, Path{}, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("root item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
if _, _, err := placeDocumentWithMode(body, destination, PlacementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes)
|
||||
_, _, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, test.depth, test.nodes)
|
||||
if test.wantAccept && err != nil {
|
||||
t.Fatalf("exact bounds rejected: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ func (e *LoadError) Sources() []SourceInfo {
|
||||
return cloneSourceInfos(e.sources)
|
||||
}
|
||||
|
||||
// Source returns a detached record from the source evidence retained before
|
||||
// loading failed. IDs are local to the failed load. Empty or unknown IDs return
|
||||
// false.
|
||||
func (e *LoadError) Source(id string) (SourceInfo, bool) {
|
||||
if e == nil {
|
||||
return SourceInfo{}, false
|
||||
}
|
||||
return lookupSourceInfo(e.sources, id)
|
||||
}
|
||||
|
||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||
// control characters and propagates writer errors, including short writes.
|
||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||
@@ -88,7 +98,9 @@ func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||
canonical := cloneDiagnostics(diagnostics)
|
||||
for i := range canonical {
|
||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||
if canonical[i].Code != CodeComposeConflict {
|
||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||
}
|
||||
}
|
||||
type orderedDiagnostic struct {
|
||||
diagnostic Diagnostic
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -110,6 +111,44 @@ func TestCanonicalDiagnosticsSortEscapedPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalDiagnosticsPreservesCompositionClaimantOrder(t *testing.T) {
|
||||
composition := Diagnostic{
|
||||
Code: CodeComposeConflict,
|
||||
Severity: SeverityError,
|
||||
Path: Keys("value"),
|
||||
Primary: &Location{SourceID: "source-4"},
|
||||
Related: []Location{
|
||||
{SourceID: "source-5"},
|
||||
{SourceID: "source-3"},
|
||||
},
|
||||
}
|
||||
other := Diagnostic{
|
||||
Code: "decode.type",
|
||||
Severity: SeverityError,
|
||||
Path: Keys("other"),
|
||||
Related: []Location{
|
||||
{SourceID: "source-5"},
|
||||
{SourceID: "source-3"},
|
||||
},
|
||||
}
|
||||
|
||||
canonical := canonicalDiagnostics([]Diagnostic{composition, other})
|
||||
var compositionResult, genericResult Diagnostic
|
||||
for _, diagnostic := range canonical {
|
||||
if diagnostic.Code == CodeComposeConflict {
|
||||
compositionResult = diagnostic
|
||||
} else {
|
||||
genericResult = diagnostic
|
||||
}
|
||||
}
|
||||
if got := []string{compositionResult.Related[0].SourceID, compositionResult.Related[1].SourceID}; !slices.Equal(got, []string{"source-5", "source-3"}) {
|
||||
t.Fatalf("composition related locations = %v, want claimant order", got)
|
||||
}
|
||||
if got := []string{genericResult.Related[0].SourceID, genericResult.Related[1].SourceID}; !slices.Equal(got, []string{"source-3", "source-5"}) {
|
||||
t.Fatalf("generic related locations = %v, want sorted order", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
|
||||
deltas := compareConfigurationValues(left.final, right.final)
|
||||
regions := make([]diffRegion, 0, len(deltas))
|
||||
for _, delta := range deltas {
|
||||
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.immutableLookup)
|
||||
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.documentLookup())
|
||||
if !leftPresent {
|
||||
leftNode = nil
|
||||
}
|
||||
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup)
|
||||
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.documentLookup())
|
||||
if !rightPresent {
|
||||
rightNode = nil
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
|
||||
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
|
||||
resolveCalls++
|
||||
configuration.Derived = configuration.Dynamic["visible"].(string)
|
||||
resolution.Record(Keys("derived"), ChangeNormalization, "derive from effective values", Keys("credential"))
|
||||
resolution.RecordNormalization(Keys("derived"), "derive from effective values", Keys("credential"))
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
@@ -53,7 +53,7 @@ func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
|
||||
!right.Inspection.secrets["/credential"] || !right.Inspection.secrets["/derived"] {
|
||||
t.Fatalf("typed or dependency-derived sensitivity was not retained: left=%#v right=%#v", left.Inspection.secrets, right.Inspection.secrets)
|
||||
}
|
||||
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("derived"), left.Inspection.immutableLookup); !present || node.Text != "before" {
|
||||
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("derived"), left.Inspection.documentLookup()); !present || node.Text != "before" {
|
||||
t.Fatalf("left frozen derived value = %#v, present=%v", node, present)
|
||||
}
|
||||
|
||||
@@ -138,10 +138,10 @@ func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
|
||||
if err != nil || len(differences) != 0 {
|
||||
t.Fatalf("credential rotation behind one reference = %#v, err = %v", differences, err)
|
||||
}
|
||||
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("optional"), left.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
|
||||
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("optional"), left.Inspection.documentLookup()); !present || node.Text != "DIFF_ROTATING_SECRET" {
|
||||
t.Fatalf("frozen reference = %#v, present=%v", node, present)
|
||||
}
|
||||
if node, present := documentAtPathWithLookup(right.Inspection.final, Keys("optional"), right.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
|
||||
if node, present := documentAtPathWithLookup(right.Inspection.final, Keys("optional"), right.Inspection.documentLookup()); !present || node.Text != "DIFF_ROTATING_SECRET" {
|
||||
t.Fatalf("right frozen reference = %#v, present=%v", node, present)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
|
||||
if absent.Config.Optional != nil {
|
||||
t.Fatalf("missing optional reference decoded as %#v", absent.Config.Optional)
|
||||
}
|
||||
if node, present := documentAtPathWithLookup(absent.Inspection.final, Keys("optional"), absent.Inspection.immutableLookup); !present || node.Kind != document.Null {
|
||||
if node, present := documentAtPathWithLookup(absent.Inspection.final, Keys("optional"), absent.Inspection.documentLookup()); !present || node.Kind != document.Null {
|
||||
t.Fatalf("optional nil projection = %#v, present=%v", node, present)
|
||||
}
|
||||
differences, err = Diff(absent.Inspection, left.Inspection)
|
||||
@@ -203,14 +203,14 @@ func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
|
||||
t.Run("removed historical value", func(t *testing.T) {
|
||||
left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
|
||||
delete(configuration.Dynamic, "token")
|
||||
resolution.Record(secretPath, ChangeNormalization, "remove token")
|
||||
resolution.RecordNormalization(secretPath, "remove token")
|
||||
return nil
|
||||
})
|
||||
right := load("dynamic: {token: current-canary}\n", nil)
|
||||
if !left.Inspection.secrets[secretPath.String()] {
|
||||
t.Fatal("removed historical selector was not retained")
|
||||
}
|
||||
if _, present := documentAtPathWithLookup(left.Inspection.final, secretPath, left.Inspection.immutableLookup); present {
|
||||
if _, present := documentAtPathWithLookup(left.Inspection.final, secretPath, left.Inspection.documentLookup()); present {
|
||||
t.Fatal("removed token remains in the effective left tree")
|
||||
}
|
||||
differences, err := Diff(left.Inspection, right.Inspection)
|
||||
@@ -228,7 +228,7 @@ func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
|
||||
t.Run("replaced historical value", func(t *testing.T) {
|
||||
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
|
||||
configuration.Dynamic["token"] = "replacement-canary"
|
||||
resolution.Record(secretPath, ChangeNormalization, "replace token")
|
||||
resolution.RecordNormalization(secretPath, "replace token")
|
||||
return nil
|
||||
})
|
||||
right := load("dynamic: {token: other-canary}\n", nil)
|
||||
|
||||
@@ -127,7 +127,10 @@ type expectedDiffRegion struct {
|
||||
}
|
||||
|
||||
func diffTestInspection(root *document.Node) *Inspection {
|
||||
return &Inspection{final: root, immutableLookup: buildDocumentLookup(root)}
|
||||
builder := newHistoryBuilder()
|
||||
builder.model.final = root
|
||||
model := builder.freeze()
|
||||
return &Inspection{final: root, history: model, metadata: Metadata{historyModel: model}}
|
||||
}
|
||||
|
||||
func assertDiffRegionNode(t *testing.T, side string, node *document.Node, wantPresent bool, wantKind document.Kind) {
|
||||
|
||||
@@ -19,11 +19,9 @@ const (
|
||||
)
|
||||
|
||||
type rootDirectiveExtraction struct {
|
||||
body *document.Node
|
||||
imports []importDeclarationSpec
|
||||
sequences []sequenceDeclarationSpec
|
||||
importsPresent bool
|
||||
sequencesPresent bool
|
||||
body *document.Node
|
||||
imports []importDeclarationSpec
|
||||
sequences []sequenceDeclarationSpec
|
||||
}
|
||||
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||
@@ -74,7 +72,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
|
||||
fieldPath := directivePath.Key(entry.Key)
|
||||
switch entry.Key {
|
||||
case "imports":
|
||||
extracted.importsPresent = true
|
||||
if context == rootDirectiveContextOverlay {
|
||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
||||
continue
|
||||
@@ -87,7 +84,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
|
||||
extracted.imports = append(extracted.imports, parsed...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
case "sequences":
|
||||
extracted.sequencesPresent = true
|
||||
if context == rootDirectiveContextImportsOnly {
|
||||
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
||||
continue
|
||||
|
||||
@@ -255,7 +255,7 @@ func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 3 {
|
||||
if len(selection.candidates) != 3 {
|
||||
t.Fatalf("selection = %#v, want three unioned matches", selection)
|
||||
}
|
||||
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
|
||||
@@ -82,7 +82,7 @@ func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics = resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
yaml string
|
||||
defaults importDefaults
|
||||
defaults ImportDefaults
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
@@ -280,13 +280,13 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
||||
{
|
||||
name: "explicit false overrides application recursion",
|
||||
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
|
||||
defaults: importDefaults{recursive: boolPointer(true)},
|
||||
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||
wantPaths: []string{"direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "application default enables recursion for depth",
|
||||
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
|
||||
defaults: importDefaults{recursive: boolPointer(true)},
|
||||
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||
},
|
||||
{
|
||||
@@ -347,13 +347,13 @@ func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
accounting := &importAccounting{}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
|
||||
if len(diagnostics) != 0 || selection.outcome != importSelectionNone || len(selection.candidates) != 0 {
|
||||
if len(diagnostics) != 0 || len(selection.candidates) != 0 {
|
||||
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if accounting.entries != 5 {
|
||||
|
||||
@@ -481,7 +481,7 @@ func resolveSearchInput(source Source, sourceID string, helpers discoveryDirecto
|
||||
if len(diagnostics) == 0 || selected.kind != sourceFile {
|
||||
return selected, nil, diagnostics
|
||||
}
|
||||
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerBase, Kind: SourceKindFile}
|
||||
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerMain, Kind: SourceKindFile}
|
||||
for index := range diagnostics {
|
||||
diagnostics[index].Primary = &Location{SourceID: sourceID}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ settings: {main: selected}
|
||||
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
|
||||
search := SearchConfig("app", SearchDirectory(root))
|
||||
options := Options[discoveryLoadConfig]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"),
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root}, Destination: Keys("service"),
|
||||
}
|
||||
searched, err := testLoad(search, options)
|
||||
if err != nil {
|
||||
@@ -77,7 +77,7 @@ settings: {underlay: value}
|
||||
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
|
||||
|
||||
sources := searched.Inspection.Sources()
|
||||
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
|
||||
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerMain || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
|
||||
t.Fatalf("layered search source evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
|
||||
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||
}{
|
||||
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{destination: Keys("settings")})
|
||||
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Destination: Keys("settings")})
|
||||
}},
|
||||
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||
|
||||
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type unsupportedDynamicRoot struct {
|
||||
Child unsupportedDynamicChild `yaml:"child"`
|
||||
Invalid chan int `yaml:"invalid"`
|
||||
}
|
||||
|
||||
type unsupportedDynamicChild struct {
|
||||
Parent *unsupportedDynamicRoot `yaml:"parent"`
|
||||
}
|
||||
|
||||
type secretDynamicRoot struct {
|
||||
Nested secretDynamicNested `yaml:"nested"`
|
||||
}
|
||||
|
||||
type secretDynamicNested struct {
|
||||
Token Secret `yaml:"token"`
|
||||
}
|
||||
|
||||
type dynamicTransactionConfig struct {
|
||||
First any `yaml:"first"`
|
||||
Second any `yaml:"second"`
|
||||
Good any `yaml:"good"`
|
||||
}
|
||||
|
||||
func TestDynamicDescriptorFailuresRollBackProvisionalTypes(t *testing.T) {
|
||||
called := false
|
||||
result, err := testLoad[dynamicTransactionConfig](NoSource(), Options[dynamicTransactionConfig]{Definition: Definition[dynamicTransactionConfig]{
|
||||
Resolve: func(config *dynamicTransactionConfig, resolution *Resolution) error {
|
||||
called = true
|
||||
Normalize(resolution, Keys("first"), &config.First, any((*unsupportedDynamicRoot)(nil)), "try typed nil")
|
||||
Normalize(resolution, Keys("second"), &config.Second, any(&unsupportedDynamicRoot{}), "try non-nil")
|
||||
Normalize(resolution, Keys("good"), &config.Good, any(map[string]string{"valid": "value"}), "continue with supported value")
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
var loadErr *LoadError
|
||||
if !called || result != nil || !errors.As(err, &loadErr) {
|
||||
t.Fatalf("result = %#v, called=%v, error=%v", result, called, err)
|
||||
}
|
||||
paths := make(map[string]Code)
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
paths[diagnostic.Path.String()] = diagnostic.Code
|
||||
}
|
||||
for _, path := range []string{"/first", "/second"} {
|
||||
if paths[path] != CodeResolveInvalidValue {
|
||||
t.Errorf("diagnostic at %s = %q, want safe invalid-value failure", path, paths[path])
|
||||
}
|
||||
}
|
||||
|
||||
schema := mustSchemaFor[dynamicTransactionConfig](t)
|
||||
operation := valueOperation{schema: schema}
|
||||
prior, issue := operation.dynamicDescriptor(reflect.TypeFor[map[string]string](), Keys("prior"))
|
||||
if issue != nil {
|
||||
t.Fatalf("prior valid dynamic descriptor issue = %#v", issue)
|
||||
}
|
||||
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("failure")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||
t.Fatalf("unsupported dynamic descriptor issue = %#v", issue)
|
||||
}
|
||||
if _, approved := operation.approvedDynamic[reflect.TypeFor[unsupportedDynamicRoot]()]; approved {
|
||||
t.Fatal("unsupported dynamic descriptor was cached as eligible")
|
||||
}
|
||||
if schema.types[reflect.TypeFor[map[string]string]()] != prior {
|
||||
t.Fatal("failed build removed a previously completed descriptor")
|
||||
}
|
||||
for _, current := range []reflect.Type{
|
||||
reflect.TypeFor[unsupportedDynamicRoot](),
|
||||
reflect.TypeFor[unsupportedDynamicChild](),
|
||||
reflect.TypeFor[*unsupportedDynamicRoot](),
|
||||
} {
|
||||
if _, exists := schema.types[current]; exists {
|
||||
t.Errorf("failed build retained provisional descriptor for %v", current)
|
||||
}
|
||||
}
|
||||
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("again")); issue == nil || issue.path.String() != "/again" {
|
||||
t.Fatalf("repeated failure issue = %#v", issue)
|
||||
}
|
||||
|
||||
secretSchema, schemaIssue := buildSchema(reflect.TypeFor[dynamicTransactionConfig](), testSecretBinding())
|
||||
if schemaIssue != nil {
|
||||
t.Fatalf("secret schema issue = %#v", schemaIssue)
|
||||
}
|
||||
secretOperation := valueOperation{schema: secretSchema}
|
||||
if _, issue := secretOperation.dynamicDescriptor(reflect.TypeFor[secretDynamicRoot](), Keys("secret")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||
t.Fatalf("dynamic secret eligibility issue = %#v", issue)
|
||||
}
|
||||
if len(secretOperation.approvedDynamic) != 0 {
|
||||
t.Fatalf("secret-bearing dynamic descriptor was cached as eligible: %#v", secretOperation.approvedDynamic)
|
||||
}
|
||||
for _, current := range []reflect.Type{reflect.TypeFor[secretDynamicRoot](), reflect.TypeFor[secretDynamicNested](), reflect.TypeFor[Secret]()} {
|
||||
if _, exists := secretSchema.types[current]; exists {
|
||||
t.Errorf("secret eligibility failure retained provisional descriptor for %v", current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type wideDynamicEligibilityValue struct {
|
||||
Alpha string
|
||||
Bravo string
|
||||
Charlie string
|
||||
Delta string
|
||||
Echo string
|
||||
Foxtrot string
|
||||
Golf string
|
||||
Hotel string
|
||||
India string
|
||||
Juliet string
|
||||
Kilo string
|
||||
Lima string
|
||||
Mike string
|
||||
November string
|
||||
Oscar string
|
||||
Papa string
|
||||
}
|
||||
|
||||
type repeatedNilDynamicEligibilityConfig struct {
|
||||
Values []any
|
||||
}
|
||||
|
||||
func TestDynamicEligibilityIsReusedWithinProjectionOperation(t *testing.T) {
|
||||
schema, schemaIssue := buildSchema(reflect.TypeFor[repeatedNilDynamicEligibilityConfig](), testSecretBinding())
|
||||
if schemaIssue != nil {
|
||||
t.Fatalf("schema issue = %#v", schemaIssue)
|
||||
}
|
||||
values := make([]any, 32)
|
||||
for index := range values {
|
||||
values[index] = (*wideDynamicEligibilityValue)(nil)
|
||||
}
|
||||
configuration := repeatedNilDynamicEligibilityConfig{Values: values}
|
||||
operation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||
node, issue := operation.project(reflect.ValueOf(configuration), schema.root, Path{}, 0)
|
||||
if issue != nil {
|
||||
t.Fatalf("repeated typed-nil projection issue = %#v", issue)
|
||||
}
|
||||
if len(node.Mapping) != 1 || len(node.Mapping[0].Value.Sequence) != len(values) {
|
||||
t.Fatalf("projected repeated values = %#v", node)
|
||||
}
|
||||
for index, child := range node.Mapping[0].Value.Sequence {
|
||||
if child.Kind != document.Null {
|
||||
t.Errorf("projected value %d kind = %v, want null", index, child.Kind)
|
||||
}
|
||||
}
|
||||
current := reflect.TypeFor[*wideDynamicEligibilityValue]()
|
||||
if len(operation.approvedDynamic) != 1 || operation.approvedDynamic[current] == nil ||
|
||||
operation.approvedDynamic[current] != schema.types[current] {
|
||||
t.Fatalf("operation eligibility cache = %#v", operation.approvedDynamic)
|
||||
}
|
||||
otherOperation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||
if len(otherOperation.approvedDynamic) != 0 {
|
||||
t.Fatalf("dynamic eligibility leaked into another operation: %#v", otherOperation.approvedDynamic)
|
||||
}
|
||||
}
|
||||
@@ -37,28 +37,39 @@ type Explanation struct {
|
||||
// Explain returns safe effective and chronological values for a known final or
|
||||
// historical path. Unknown and malformed paths return false.
|
||||
func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
||||
if r == nil || r.final == nil || !path.valid() {
|
||||
return Explanation{}, false
|
||||
}
|
||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, r.immutableLookup)}
|
||||
model := r.history
|
||||
if model == nil {
|
||||
return Explanation{}, false
|
||||
}
|
||||
selected := model.selectHistory(path)
|
||||
if len(selected) == 0 && !model.hasObservedShape(path) {
|
||||
return Explanation{}, false
|
||||
}
|
||||
selections := model.indexReplaySelections(selected)
|
||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)}
|
||||
if info, ok := r.metadata.Lookup(path); ok {
|
||||
explanation.Origin = cloneOrigin(info.Origin)
|
||||
}
|
||||
|
||||
working := document.Clone(r.baseline)
|
||||
if _, present := documentAtPath(working, path); present {
|
||||
working := document.Clone(model.baseline)
|
||||
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
|
||||
After: valueSnapshot(working, path, r.secrets),
|
||||
Origin: cloneOrigin(baseline.attribution.origin),
|
||||
})
|
||||
}
|
||||
replayIndex := new(mappingIndex)
|
||||
for _, application := range r.applications {
|
||||
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
before = valueSnapshot(working, path, r.secrets)
|
||||
for payloadID, payload := range model.sourcePayloads {
|
||||
if payload.kind != historySourceApplicationPayload {
|
||||
continue
|
||||
}
|
||||
application := payload.application
|
||||
before := valueSnapshot(working, path, r.secrets)
|
||||
applyHistoryApplication(&working, application, replayIndex)
|
||||
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
@@ -69,37 +80,38 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: after,
|
||||
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
|
||||
Origin: cloneOrigin(selected.attribution.origin),
|
||||
})
|
||||
}
|
||||
appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets)
|
||||
appendInheritanceExplanation(&explanation, &working, path, model, selections, r.secrets)
|
||||
|
||||
// Intermediate input replacements do not permanently suppress defaults:
|
||||
// decoding applies them to the final input tree. Show that reconciliation
|
||||
// explicitly before typed CLI contributions and domain resolution.
|
||||
decodedInput := r.preCLI
|
||||
decodedOrigins := r.preCLIOrigins
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
decoded, _ := documentAtPathWithLookup(decodedInput, path, r.immutableLookup)
|
||||
if !configurationValuesEqual(replayed, decoded) {
|
||||
origin := cloneOrigin(decodedOrigins[path.String()])
|
||||
origin.Operation = ChangeDecoding
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(decodedInput, path, r.secrets, r.immutableLookup),
|
||||
Origin: origin,
|
||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||
})
|
||||
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
|
||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
decoded, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
||||
if selected && !configurationValuesEqual(replayed, decoded) {
|
||||
origin, reasons := checkpointExplanationAttribution(selection)
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
||||
Origin: origin,
|
||||
Reasons: reasons,
|
||||
})
|
||||
}
|
||||
working = document.Clone(checkpoint.root)
|
||||
}
|
||||
|
||||
working = document.Clone(decodedInput)
|
||||
for _, application := range r.postDecodeApps {
|
||||
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
before = valueSnapshot(working, path, r.secrets)
|
||||
for payloadID, payload := range model.cliPayloads {
|
||||
if payload.kind != historySourceApplicationPayload {
|
||||
continue
|
||||
}
|
||||
applyHistoryApplication(&working, application, replayIndex)
|
||||
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
|
||||
before := valueSnapshot(working, path, r.secrets)
|
||||
applyHistoryApplication(&working, payload.application, replayIndex)
|
||||
selected, relevant := selections.application(applicationRef)
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
@@ -110,43 +122,45 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: after,
|
||||
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
|
||||
Origin: cloneOrigin(selected.attribution.origin),
|
||||
})
|
||||
}
|
||||
replayed, _ = documentAtPath(working, path)
|
||||
effectiveInput, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
|
||||
if !configurationValuesEqual(replayed, effectiveInput) {
|
||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||
origin.Operation = ChangeDecoding
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
|
||||
Origin: origin,
|
||||
Reasons: []string{"applied the typed CLI assignment"},
|
||||
})
|
||||
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
|
||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
||||
if selected && !configurationValuesEqual(replayed, effectiveInput) {
|
||||
origin, reasons := checkpointExplanationAttribution(selection)
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
||||
Origin: origin,
|
||||
Reasons: reasons,
|
||||
})
|
||||
}
|
||||
working = document.Clone(checkpoint.root)
|
||||
}
|
||||
working = document.Clone(r.input)
|
||||
for _, snapshot := range r.resolutionReplay {
|
||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
before = valueSnapshot(working, path, r.secrets)
|
||||
if model.resolutionPayload != nil {
|
||||
for _, group := range model.resolutionPayload.replayGroups {
|
||||
selection, selected := selections.resolutionGroup(group)
|
||||
if !selected || group.coveringRecordID < 0 || group.coveringRecordID >= len(model.resolutionPayload.snapshots) {
|
||||
continue
|
||||
}
|
||||
covering := model.resolutionPayload.snapshots[group.coveringRecordID]
|
||||
before := valueSnapshot(working, path, r.secrets)
|
||||
replaceHistoryPath(&working, group.change.Path, covering.after)
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: after,
|
||||
Origin: cloneOrigin(selection.attribution.origin),
|
||||
Reasons: append([]string(nil), group.change.Reasons...),
|
||||
Dependencies: clonePaths(group.change.Dependencies),
|
||||
})
|
||||
}
|
||||
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: after,
|
||||
Origin: cloneOrigin(snapshot.change.Origin),
|
||||
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
||||
Dependencies: clonePaths(snapshot.change.Dependencies),
|
||||
})
|
||||
}
|
||||
|
||||
if !explanation.Effective.Present && len(explanation.History) == 0 {
|
||||
@@ -157,3 +171,75 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
}
|
||||
return explanation, true
|
||||
}
|
||||
|
||||
// historyReplaySelections is query-local dispatch state, not another evidence
|
||||
// or attribution owner. Replay still follows the model's ordered payloads.
|
||||
type historyReplaySelections struct {
|
||||
byPayload map[historyPayloadRef]historySelection
|
||||
applicationFallbacks map[historyPayloadRef]historySelection
|
||||
}
|
||||
|
||||
func (model *historyModel) indexReplaySelections(ordered []historySelection) historyReplaySelections {
|
||||
indexed := historyReplaySelections{
|
||||
byPayload: make(map[historyPayloadRef]historySelection, len(ordered)),
|
||||
applicationFallbacks: make(map[historyPayloadRef]historySelection),
|
||||
}
|
||||
for _, selection := range ordered {
|
||||
ref := selection.payload
|
||||
indexed.byPayload[ref] = selection
|
||||
if selection.role != historyRoleOperation || ref.substep != 0 {
|
||||
continue
|
||||
}
|
||||
var payloads []historySourcePayload
|
||||
switch ref.phase {
|
||||
case historyPhaseSources:
|
||||
payloads = model.sourcePayloads
|
||||
case historyPhaseCLI:
|
||||
payloads = model.cliPayloads
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if ref.id < 0 || ref.id >= len(payloads) {
|
||||
continue
|
||||
}
|
||||
payload := payloads[ref.id]
|
||||
if payload.kind == historySourceOverlayPayload && payload.hasApplicationRef {
|
||||
indexed.applicationFallbacks[payload.applicationRef] = selection
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
func (selections historyReplaySelections) application(ref historyPayloadRef) (historySelection, bool) {
|
||||
if direct, found := selections.byPayload[ref]; found {
|
||||
return direct, true
|
||||
}
|
||||
// Only removed/replaced paths lacking a direct contribution need fallback.
|
||||
selection, found := selections.applicationFallbacks[ref]
|
||||
return selection, found
|
||||
}
|
||||
|
||||
func checkpointExplanationAttribution(selection historySelection) (Origin, []string) {
|
||||
if selection.hasAttribution {
|
||||
return cloneOrigin(selection.attribution.origin), append([]string(nil), selection.attribution.reasons...)
|
||||
}
|
||||
return Origin{Operation: ChangeDecoding}, nil
|
||||
}
|
||||
|
||||
func (selections historyReplaySelections) resolutionGroup(group resolutionReplayGroup) (historySelection, bool) {
|
||||
var fallback historySelection
|
||||
found := false
|
||||
for _, recordID := range group.originalRecordIDs {
|
||||
selection, ok := selections.byPayload[historyPayloadRef{phase: historyPhaseResolution, id: recordID}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if recordID == group.coveringRecordID {
|
||||
return selection, true
|
||||
}
|
||||
if !found {
|
||||
fallback, found = selection, true
|
||||
}
|
||||
}
|
||||
return fallback, found
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
|
||||
rendered.WriteString("input to canonical typed\n")
|
||||
} else if change.Origin.Operation == ChangeInheritance {
|
||||
rendered.WriteString("structural inheritance\n")
|
||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerUnderlay || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
||||
} else if change.Origin.Layer == LayerMain || change.Origin.Layer == LayerUnderlay || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
|
||||
rendered.WriteString("policy-validated input\n")
|
||||
} else {
|
||||
rendered.WriteString("canonical typed\n")
|
||||
|
||||
@@ -137,7 +137,7 @@ dynamic: selected
|
||||
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
||||
t.Fatalf("concurrency history = %#v", got)
|
||||
}
|
||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}) {
|
||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerMain, LayerOverlay, LayerCLI, LayerResolution}) {
|
||||
t.Fatalf("concurrency layers = %#v", got)
|
||||
}
|
||||
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||
@@ -265,7 +265,7 @@ dynamic: selected
|
||||
var invalidInput bytes.Buffer
|
||||
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
||||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
||||
strings.Contains(invalidInput.String(), "wrong-before-overlay\n source: layer=\"base\"\n before (canonical typed)") {
|
||||
strings.Contains(invalidInput.String(), "wrong-before-overlay\n source: layer=\"main\"\n before (canonical typed)") {
|
||||
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
||||
}
|
||||
}
|
||||
@@ -308,12 +308,30 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
|
||||
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
|
||||
leaf := &document.Node{Kind: document.String, Text: "value"}
|
||||
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
|
||||
applications := []historyApplication{{layer: LayerBase, value: root}}
|
||||
if !historyNodesWithin(2, root, root, root, applications, nil) {
|
||||
builder := newHistoryBuilder()
|
||||
builder.model.baseline = root
|
||||
builder.model.final = root
|
||||
builder.model.sourcePayloads = []historySourcePayload{{
|
||||
kind: historySourceApplicationPayload, application: historyApplication{layer: LayerMain, value: root},
|
||||
}}
|
||||
model := builder.freeze()
|
||||
inspection := &Inspection{history: model, metadata: Metadata{historyModel: model}, final: root}
|
||||
counter := newRetainedNodeCounter(2)
|
||||
if !addInspectionHistory(counter, inspection) || counter.count != 2 {
|
||||
t.Fatal("structurally shared history was counted more than once")
|
||||
}
|
||||
other := document.Clone(root)
|
||||
if historyNodesWithin(3, root, other, root, applications, nil) {
|
||||
builder = newHistoryBuilder()
|
||||
builder.model.baseline = root
|
||||
builder.model.final = root
|
||||
builder.model.sourcePayloads = []historySourcePayload{{
|
||||
kind: historySourceApplicationPayload, application: historyApplication{layer: LayerMain, value: other},
|
||||
}}
|
||||
model = builder.freeze()
|
||||
inspection.history = model
|
||||
inspection.metadata.historyModel = model
|
||||
counter = newRetainedNodeCounter(3)
|
||||
if addInspectionHistory(counter, inspection) {
|
||||
t.Fatal("distinct retained nodes did not consume the budget")
|
||||
}
|
||||
}
|
||||
@@ -383,6 +401,11 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||
if last.Origin.Layer != info.Origin.Layer {
|
||||
t.Fatal("decoding lost effective origin")
|
||||
}
|
||||
for _, metadataChange := range result.Metadata().History(path) {
|
||||
if metadataChange.Origin.Operation == ChangeDecoding {
|
||||
t.Fatal("typed reconciliation leaked into metadata history")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -414,7 +437,7 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.
|
||||
if first.Config.Version != "4" || second.Config.Version != "4" {
|
||||
t.Fatalf("configurations = %#v / %#v", first.Config, second.Config)
|
||||
}
|
||||
inputVersion, inputOK := documentAtPath(first.applications[0].value, Keys("version"))
|
||||
inputVersion, inputOK := documentAtPath(first.history.sourcePayloads[0].application.value, Keys("version"))
|
||||
finalVersion, finalOK := documentAtPath(first.final, Keys("version"))
|
||||
if !inputOK || inputVersion.Kind != document.Number || inputVersion.Text != "4" ||
|
||||
!finalOK || finalVersion.Kind != document.String || finalVersion.Text != "4" {
|
||||
@@ -422,7 +445,7 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.
|
||||
}
|
||||
|
||||
info, ok := first.Metadata().Lookup(Keys("version"))
|
||||
if !ok || !info.Explicit || info.Null || info.Origin.Layer != LayerBase || len(info.Origin.Locations) != 1 ||
|
||||
if !ok || !info.Explicit || info.Null || info.Origin.Layer != LayerMain || len(info.Origin.Locations) != 1 ||
|
||||
info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 1 {
|
||||
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||
}
|
||||
@@ -432,7 +455,7 @@ func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.
|
||||
}
|
||||
foundInput, foundDecoding := false, false
|
||||
for _, change := range explanation.History {
|
||||
if change.After.YAML == "4\n" && change.Origin.Layer == LayerBase {
|
||||
if change.After.YAML == "4\n" && change.Origin.Layer == LayerMain {
|
||||
foundInput = true
|
||||
}
|
||||
if change.Before.YAML == "4\n" && change.After.YAML == "'4'\n" && change.Origin.Operation == ChangeDecoding {
|
||||
@@ -502,10 +525,10 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
|
||||
result, err := testLoad(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||
c.M["new"] = map[string]any{"child": int64(7)}
|
||||
delete(c.M, "old")
|
||||
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))
|
||||
r.Record(Keys("m", "old"), ChangeNormalization, "remove old")
|
||||
r.RecordDerivedDefault(Keys("m", "new"), "add nested", Keys("m", "old"))
|
||||
r.RecordNormalization(Keys("m", "old"), "remove old")
|
||||
if overlap {
|
||||
r.Record(Keys("m"), ChangeNormalization, "update container")
|
||||
r.RecordNormalization(Keys("m"), "update container")
|
||||
}
|
||||
return nil
|
||||
}}})
|
||||
@@ -529,6 +552,25 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
|
||||
if overlap && count != 1 {
|
||||
t.Fatalf("overlapping records applied %d times", count)
|
||||
}
|
||||
if overlap && path.String() == "/m" {
|
||||
metadataHistory := result.Metadata().History(path)
|
||||
var sharedResolutionOrigin bool
|
||||
for _, metadataChange := range metadataHistory {
|
||||
if metadataChange.Origin.Layer != LayerResolution {
|
||||
continue
|
||||
}
|
||||
for _, explanationChange := range x.History {
|
||||
if explanationChange.Origin.Layer == metadataChange.Origin.Layer &&
|
||||
explanationChange.Origin.Operation == metadataChange.Origin.Operation &&
|
||||
reflect.DeepEqual(explanationChange.Origin.Locations, metadataChange.Origin.Locations) {
|
||||
sharedResolutionOrigin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sharedResolutionOrigin {
|
||||
t.Fatalf("metadata and explanation disagree on overlapping resolution attribution: metadata %#v, explanation %#v", metadataHistory, x.History)
|
||||
}
|
||||
}
|
||||
last := x.History[len(x.History)-1]
|
||||
if last.After != x.Effective {
|
||||
t.Fatalf("incorrect replay for %s: %#v", path, x)
|
||||
@@ -545,7 +587,7 @@ func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) {
|
||||
result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{
|
||||
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||
value.Count = 2
|
||||
resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count"))
|
||||
resolution.RecordNormalization(Keys("count"), "normalized", Keys("count"))
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
@@ -593,7 +635,7 @@ func TestConcurrentReadOnlyInspectionQueries(t *testing.T) {
|
||||
result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{
|
||||
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||
value.Group.Value = "resolved"
|
||||
resolution.Record(Keys("group", "value"), ChangeNormalization, "normalized", Keys("group", "value"))
|
||||
resolution.RecordNormalization(Keys("group", "value"), "normalized", Keys("group", "value"))
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
|
||||
@@ -141,40 +141,59 @@ func (a *filesystemAccess) open(name string) (fs.File, error) {
|
||||
return a.root.Open(relative)
|
||||
}
|
||||
|
||||
type directoryReadError struct {
|
||||
opened bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *directoryReadError) Error() string { return "directory could not be read" }
|
||||
func (e *directoryReadError) Unwrap() error { return e.err }
|
||||
|
||||
func missingDirectoryOpen(err error) bool {
|
||||
var failure *directoryReadError
|
||||
return errors.As(err, &failure) && !failure.opened && errors.Is(err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
|
||||
file, err := a.open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
return &directoryReadError{err: err}
|
||||
}
|
||||
failure := func(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &directoryReadError{opened: true, err: err}
|
||||
}
|
||||
if reader, ok := file.(fs.ReadDirFile); ok {
|
||||
for {
|
||||
entries, readErr := reader.ReadDir(globReadBatch)
|
||||
for _, entry := range entries {
|
||||
if !visit(entry) {
|
||||
return file.Close()
|
||||
return failure(file.Close())
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
return file.Close()
|
||||
return failure(file.Close())
|
||||
}
|
||||
if readErr != nil {
|
||||
_ = file.Close()
|
||||
return readErr
|
||||
return failure(readErr)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
_ = file.Close()
|
||||
return io.ErrNoProgress
|
||||
return failure(io.ErrNoProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
return failure(err)
|
||||
}
|
||||
// fs.ReadDir provides the standard fallback for implementations whose opened
|
||||
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
|
||||
entries, err := fs.ReadDir(a.native, name)
|
||||
if err != nil {
|
||||
return err
|
||||
return failure(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !visit(entry) {
|
||||
|
||||
@@ -119,21 +119,34 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
||||
}
|
||||
|
||||
// A link stays active until its target has resolved, not until the entire
|
||||
// original pathname has resolved. Remaining segments may reuse a finished link.
|
||||
// A link remains active until its own target resolves. Completed targets can be
|
||||
// reused while the caller continues resolving its remaining components.
|
||||
type importLinkFrame struct {
|
||||
pending []string
|
||||
link string
|
||||
}
|
||||
|
||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}}
|
||||
type importLinkOperations struct {
|
||||
lstat func(string) (fs.FileInfo, error)
|
||||
readlink func(string) (string, error)
|
||||
normalizeTarget func(string, string) (string, error)
|
||||
}
|
||||
|
||||
func resolveImportLinkPath(name string, operations importLinkOperations) (string, error) {
|
||||
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
active := make(map[string]struct{})
|
||||
completed := make(map[string]string)
|
||||
for len(frames) > 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if len(frame.pending) == 0 {
|
||||
delete(activeLinks, frame.link)
|
||||
if frame.link != "" {
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
completed[frame.link] = resolved
|
||||
delete(active, frame.link)
|
||||
}
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
@@ -143,7 +156,7 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
info, err := access.root.Lstat(filepath.FromSlash(candidate))
|
||||
info, err := operations.lstat(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -151,27 +164,22 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
if target, ok := completed[candidate]; ok {
|
||||
resolved = target
|
||||
continue
|
||||
}
|
||||
if _, exists := active[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := access.root.Readlink(filepath.FromSlash(candidate))
|
||||
target, err := operations.readlink(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var targetPath string
|
||||
if filepath.IsAbs(target) {
|
||||
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
|
||||
if err != nil {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath = path.Clean(filepath.ToSlash(relative))
|
||||
} else {
|
||||
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
|
||||
}
|
||||
if !withinPortableRoot(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
targetPath, err := operations.normalizeTarget(candidate, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
active[candidate] = struct{}{}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
}
|
||||
@@ -181,6 +189,37 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||
return resolveImportLinkPath(filepath.ToSlash(name), hostImportLinkOperations(access))
|
||||
}
|
||||
|
||||
func hostImportLinkOperations(access *filesystemAccess) importLinkOperations {
|
||||
return importLinkOperations{
|
||||
lstat: func(candidate string) (fs.FileInfo, error) {
|
||||
return access.root.Lstat(filepath.FromSlash(candidate))
|
||||
},
|
||||
readlink: func(candidate string) (string, error) {
|
||||
return access.root.Readlink(filepath.FromSlash(candidate))
|
||||
},
|
||||
normalizeTarget: func(candidate, target string) (string, error) {
|
||||
var targetPath string
|
||||
if filepath.IsAbs(target) {
|
||||
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
|
||||
if err != nil {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath = path.Clean(filepath.ToSlash(relative))
|
||||
} else {
|
||||
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
|
||||
}
|
||||
if !withinPortableRoot(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
return targetPath, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func withinPortableRoot(name string) bool {
|
||||
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
|
||||
}
|
||||
@@ -189,57 +228,27 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
|
||||
if !fs.ValidPath(name) || !access.contains(name) {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
for len(frames) > 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if len(frame.pending) == 0 {
|
||||
delete(activeLinks, frame.link)
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
segment := frame.pending[0]
|
||||
frame.pending = frame.pending[1:]
|
||||
candidate := path.Join(resolved, segment)
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
// Ancestors of a multi-component root are resolution prefixes, not
|
||||
// selected targets. Complete paths and link targets remain contained.
|
||||
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
info, err := filesystem.Lstat(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := filesystem.ReadLink(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if target == "" || strings.HasPrefix(target, "/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
|
||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
return resolveImportLinkPath(name, importLinkOperations{
|
||||
lstat: func(candidate string) (fs.FileInfo, error) {
|
||||
// Ancestors of a multi-component root are resolution prefixes, not
|
||||
// selected targets. Complete paths and link targets remain contained.
|
||||
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
|
||||
return nil, errImportPathOutsideRoot
|
||||
}
|
||||
return filesystem.Lstat(candidate)
|
||||
},
|
||||
readlink: filesystem.ReadLink,
|
||||
normalizeTarget: func(candidate, target string) (string, error) {
|
||||
if target == "" || strings.HasPrefix(target, "/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
|
||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
return targetPath, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func isImportLinkCycle(err error) bool {
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -45,3 +46,270 @@ func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
||||
// The small graph stays within host symlink-depth limits while exceeding the
|
||||
// bounded linear operation allowance without completed-target reuse.
|
||||
const linkCount = 5
|
||||
root := t.TempDir()
|
||||
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||
for _, directory := range []string{
|
||||
filepath.Join(configurationRoot, "graph"),
|
||||
filepath.Join(configurationRoot, "selected"),
|
||||
} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for index := 0; index < linkCount; index++ {
|
||||
name := fmt.Sprintf("s%02d", index)
|
||||
target := "."
|
||||
if index > 0 {
|
||||
previous := fmt.Sprintf("s%02d", index-1)
|
||||
target = previous + "/" + previous
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(configurationRoot, "graph", name)); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configurationRoot, "graph", "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(fmt.Sprintf("../graph/s%02d", linkCount-1), filepath.Join(configurationRoot, "selected", "entry")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
for _, selector := range []string{"directory", "glob"} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
base := os.DirFS(root)
|
||||
if _, ok := base.(fs.ReadLinkFS); !ok {
|
||||
t.Skip("host directory filesystem does not expose link inspection")
|
||||
}
|
||||
filesystem := newCountingReadLinkFS(base, linkCount*20+40)
|
||||
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
||||
if _, err := access.canonical("workspace/config/selected"); err != nil {
|
||||
t.Fatalf("selected directory canonicalization failed before selection: %v", err)
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
if selector == "directory" {
|
||||
_, diagnostics = selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: true\n")
|
||||
} else {
|
||||
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, "workspace/config", " - glob: 'selected/*/*.yml'\n follow: true\n")
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v after %d link operations", diagnostics, filesystem.operations)
|
||||
}
|
||||
if filesystem.operations == 0 || filesystem.operations > filesystem.limit {
|
||||
t.Fatalf("link-resolution operations = %d, want 1..%d", filesystem.operations, filesystem.limit)
|
||||
}
|
||||
if filesystem.readLinks < linkCount {
|
||||
t.Fatalf("link reads = %d, want the selector to resolve the repeated dependency graph", filesystem.readLinks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeLinkResolutionRejectsEscapesAndSkipsWhenFollowingIsDisabled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||
selected := filepath.Join(configurationRoot, "selected")
|
||||
if err := os.MkdirAll(selected, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../../../outside", filepath.Join(selected, "escape")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
base := os.DirFS(root)
|
||||
if _, ok := base.(fs.ReadLinkFS); !ok {
|
||||
t.Skip("host directory filesystem does not expose link inspection")
|
||||
}
|
||||
filesystem := newCountingReadLinkFS(base, 100)
|
||||
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
||||
if _, err := resolveNativeImportPath(access, filesystem.links, "workspace/config/selected/escape"); !errors.Is(err, errImportPathOutsideRoot) {
|
||||
t.Fatalf("escaping native link error = %v, want outside-root rejection", err)
|
||||
}
|
||||
|
||||
filesystem.readLinks = 0
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: false\n")
|
||||
if len(diagnostics) != 0 || filesystem.readLinks != 0 {
|
||||
t.Fatalf("follow-disabled selection diagnostics=%#v readlink calls=%d", diagnostics, filesystem.readLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
||||
// The small graph stays within host symlink-depth limits while exceeding the
|
||||
// bounded linear operation allowance without completed-target reuse.
|
||||
const linkCount = 5
|
||||
root := t.TempDir()
|
||||
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||
graph := filepath.Join(configurationRoot, "graph")
|
||||
selected := filepath.Join(configurationRoot, "selected")
|
||||
for _, directory := range []string{graph, selected} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for index := 0; index < linkCount; index++ {
|
||||
name := fmt.Sprintf("s%02d", index)
|
||||
target := "."
|
||||
if index > 0 {
|
||||
previous := fmt.Sprintf("s%02d", index-1)
|
||||
target = previous + "/" + previous
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(graph, name)); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(graph, "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliasTarget := fmt.Sprintf("../graph/s%02d", linkCount-1)
|
||||
if err := os.Symlink(aliasTarget, filepath.Join(selected, "entry")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
containedAbsolute := filepath.Join(graph, "s00")
|
||||
if err := os.Symlink(containedAbsolute, filepath.Join(selected, "absolute")); err != nil {
|
||||
t.Skipf("absolute symlinks unavailable: %v", err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(selected, "escape")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
name := filepath.ToSlash(filepath.Join("workspace", "config", "selected", "entry"))
|
||||
operations := hostImportLinkOperations(access)
|
||||
var operationCount int
|
||||
lstat := operations.lstat
|
||||
readlink := operations.readlink
|
||||
operations.lstat = func(candidate string) (fs.FileInfo, error) {
|
||||
operationCount++
|
||||
if operationCount > linkCount*20+40 {
|
||||
return nil, errors.New("host link operation limit exceeded")
|
||||
}
|
||||
return lstat(candidate)
|
||||
}
|
||||
operations.readlink = func(candidate string) (string, error) {
|
||||
operationCount++
|
||||
if operationCount > linkCount*20+40 {
|
||||
return "", errors.New("host link operation limit exceeded")
|
||||
}
|
||||
return readlink(candidate)
|
||||
}
|
||||
resolved, err := resolveImportLinkPath(name, operations)
|
||||
if err != nil || resolved != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
||||
t.Fatalf("host repeated-link resolution = %q, %v", resolved, err)
|
||||
}
|
||||
if operationCount == 0 || operationCount > linkCount*20+40 {
|
||||
t.Fatalf("host link operations = %d, want 1..%d", operationCount, linkCount*20+40)
|
||||
}
|
||||
|
||||
contained, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "absolute"))
|
||||
if err != nil || contained != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
||||
t.Fatalf("contained absolute link = %q, %v", contained, err)
|
||||
}
|
||||
if _, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "escape")); !errors.Is(err, errImportPathOutsideRoot) {
|
||||
t.Fatalf("escaping absolute link error = %v, want outside-root rejection", err)
|
||||
}
|
||||
|
||||
for _, selector := range []string{"directory", "glob"} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
var diagnostics []Diagnostic
|
||||
if selector == "directory" {
|
||||
_, diagnostics = selectDirectoryWithDiagnostics(t, access, root, " - dir: workspace/config/selected/entry\n follow: true\n")
|
||||
} else {
|
||||
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, root, " - glob: 'workspace/config/selected/entry/*.yml'\n follow: true\n")
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("host selector diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostLinkResolutionKeepsNamespacesIndependent(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{name: "left", target: "left-target"},
|
||||
{name: "right", target: "right-target"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, test.target), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(test.target, filepath.Join(root, "alias")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
if got, err := resolveHostImportPath(access, "alias"); err != nil || got != test.target {
|
||||
t.Fatalf("namespace link = %q, %v; want %q", got, err, test.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type countingReadLinkFS struct {
|
||||
filesystem fs.FS
|
||||
links fs.ReadLinkFS
|
||||
operations int
|
||||
readLinks int
|
||||
limit int
|
||||
}
|
||||
|
||||
func newCountingReadLinkFS(filesystem fs.FS, limit int) *countingReadLinkFS {
|
||||
links, ok := filesystem.(fs.ReadLinkFS)
|
||||
if !ok {
|
||||
panic("test filesystem does not support link inspection")
|
||||
}
|
||||
return &countingReadLinkFS{filesystem: filesystem, links: links, limit: limit}
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) Open(name string) (fs.File, error) { return f.filesystem.Open(name) }
|
||||
|
||||
func (f *countingReadLinkFS) Stat(name string) (fs.FileInfo, error) {
|
||||
if statter, ok := f.filesystem.(fs.StatFS); ok {
|
||||
return statter.Stat(name)
|
||||
}
|
||||
return fs.Stat(f.filesystem, name)
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if reader, ok := f.filesystem.(fs.ReadDirFS); ok {
|
||||
return reader.ReadDir(name)
|
||||
}
|
||||
return fs.ReadDir(f.filesystem, name)
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) count() error {
|
||||
f.operations++
|
||||
if f.operations > f.limit {
|
||||
return errors.New("native link operation limit exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) Lstat(name string) (fs.FileInfo, error) {
|
||||
if err := f.count(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.links.Lstat(name)
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) ReadLink(name string) (string, error) {
|
||||
if err := f.count(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f.readLinks++
|
||||
return f.links.ReadLink(name)
|
||||
}
|
||||
|
||||
@@ -56,45 +56,13 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := path.Match(segment, entry.Name())
|
||||
if err != nil {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||
candidate, selected, diagnostic := prepareGlobEntry(access, basePath, parent, entry, segment, last, policy)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
if selected {
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
accessPath := access.join(parent.accessPath, entry.Name())
|
||||
lexicalPath := access.join(parent.path, entry.Name())
|
||||
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
||||
if err != nil {
|
||||
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
|
||||
}
|
||||
if target.symlink && !policy.follow {
|
||||
continue
|
||||
}
|
||||
if !last && !target.dir {
|
||||
continue
|
||||
}
|
||||
physicalPath := target.identity
|
||||
// Native source identity and relative imports use the namespace
|
||||
// spelling; resolved link identities belong only to discovery.
|
||||
if access.native != nil {
|
||||
physicalPath = accessPath
|
||||
}
|
||||
if physicalPath == "" {
|
||||
physicalPath, err = access.canonical(accessPath)
|
||||
if err != nil {
|
||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
|
||||
}
|
||||
}
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
|
||||
}
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
}
|
||||
candidates = candidatesFromMap(nextByPath)
|
||||
@@ -120,12 +88,53 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
|
||||
// Source allowance is charged by source admission, after this filtered set
|
||||
// reaches the traversal; that preserves repeated-source diagnostics first.
|
||||
selection := importSelection{candidates: selected}
|
||||
if len(selected) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func prepareGlobEntry(access *filesystemAccess, basePath string, parent selectedImportCandidate, entry fs.DirEntry, pattern string, last bool, policy resolvedImportPolicy) (selectedImportCandidate, bool, *Diagnostic) {
|
||||
name := entry.Name()
|
||||
matched, err := path.Match(pattern, name)
|
||||
if err != nil {
|
||||
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportInvalid, "import glob pattern is invalid")
|
||||
}
|
||||
if !matched || !policy.hidden && strings.HasPrefix(name, ".") {
|
||||
return selectedImportCandidate{}, false, nil
|
||||
}
|
||||
|
||||
accessPath := access.join(parent.accessPath, name)
|
||||
lexicalPath := access.join(parent.path, name)
|
||||
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
||||
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
||||
if err != nil {
|
||||
diagnostic := globCandidateDiagnostic(err, parent.site.location, "glob match could not be inspected")
|
||||
return selectedImportCandidate{}, false, &diagnostic
|
||||
}
|
||||
if target.symlink && !policy.follow || !last && !target.dir {
|
||||
return selectedImportCandidate{}, false, nil
|
||||
}
|
||||
|
||||
physicalPath := target.identity
|
||||
// Native source identity and relative imports use the namespace spelling;
|
||||
// resolved link identities belong only to discovery.
|
||||
if access.native != nil {
|
||||
physicalPath = accessPath
|
||||
}
|
||||
if physicalPath == "" {
|
||||
physicalPath, err = access.canonical(accessPath)
|
||||
if err != nil {
|
||||
diagnostic := globFilesystemDiagnostic(err, "glob match could not be canonicalized", parent.site.location)
|
||||
return selectedImportCandidate{}, false, &diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
return selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: parent.site,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
||||
if !access.contains(candidatePath) || !access.contains(accessPath) {
|
||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
@@ -191,7 +200,7 @@ func readGlobEntries(access *filesystemAccess, directory string, location Locati
|
||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if missingDirectoryOpen(err) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, ba
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func addInspectionHistory(counter *retainedNodeCounter, inspection *Inspection) bool {
|
||||
if inspection == nil || inspection.history == nil {
|
||||
return false
|
||||
}
|
||||
for _, root := range inspection.history.documentRoots() {
|
||||
if !counter.add(root) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return counter.add(inspection.final)
|
||||
}
|
||||
|
||||
// History applications borrow immutable policy-validated input trees. Metadata
|
||||
// records answer presence and origin queries; replay reconstructs value changes.
|
||||
// Replay reconciles with canonical decoding, applies supplied CLI contributions,
|
||||
@@ -237,10 +249,6 @@ func applyHistoryApplication(root **document.Node, application historyApplicatio
|
||||
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
|
||||
}
|
||||
|
||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
|
||||
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
|
||||
}
|
||||
|
||||
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
|
||||
if incoming == nil {
|
||||
return
|
||||
@@ -337,6 +345,27 @@ func applySequenceHistory(target **document.Node, incoming *document.Node, contr
|
||||
}
|
||||
}
|
||||
|
||||
func sequenceHistoryContributionForCombination(
|
||||
destination Path,
|
||||
operation sequenceMode,
|
||||
incoming *document.Node,
|
||||
combined sequenceCombinationResult,
|
||||
) sequenceHistoryContribution {
|
||||
contribution := sequenceHistoryContribution{
|
||||
path: destination.clone(), mode: sequenceHistoryMapped,
|
||||
}
|
||||
if operation == sequenceModeReplace {
|
||||
contribution.mode = sequenceHistoryReplace
|
||||
if incoming != nil && incoming.Kind == document.Sequence {
|
||||
contribution.length = len(incoming.Sequence)
|
||||
}
|
||||
return contribution
|
||||
}
|
||||
contribution.items = append([]sequenceItemProjection(nil), combined.higherItems...)
|
||||
contribution.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
|
||||
return contribution
|
||||
}
|
||||
|
||||
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
|
||||
if values == nil {
|
||||
return nil
|
||||
@@ -395,12 +424,12 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
|
||||
// Resolution records describe one before/final transformation, not assignment
|
||||
// order. A parent already carries its descendants' final values; merge overlapping
|
||||
// intent into that event rather than replay a child assignment a second time.
|
||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionReplayGroup {
|
||||
groups := make([]resolutionReplayGroup, 0, len(snapshots))
|
||||
reasons := make([]map[string]struct{}, 0, len(snapshots))
|
||||
dependencies := make([]map[string]struct{}, 0, len(snapshots))
|
||||
var coverage pathPrefixIndex
|
||||
for _, snapshot := range snapshots {
|
||||
for recordID, snapshot := range snapshots {
|
||||
pointer := snapshot.change.Path.String()
|
||||
groupIndex := -1
|
||||
coverage.visitAncestors(pointer, func(id int) {
|
||||
@@ -410,6 +439,7 @@ func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSn
|
||||
})
|
||||
if groupIndex >= 0 {
|
||||
group := &groups[groupIndex]
|
||||
group.originalRecordIDs = append(group.originalRecordIDs, recordID)
|
||||
for _, reason := range snapshot.change.Reasons {
|
||||
if _, found := reasons[groupIndex][reason]; !found {
|
||||
reasons[groupIndex][reason] = struct{}{}
|
||||
@@ -428,15 +458,19 @@ func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSn
|
||||
}
|
||||
continue
|
||||
}
|
||||
snapshot.change = cloneChange(snapshot.change)
|
||||
groups = append(groups, snapshot)
|
||||
reasonSet := make(map[string]struct{}, len(snapshot.change.Reasons))
|
||||
for _, reason := range snapshot.change.Reasons {
|
||||
change := cloneChange(snapshot.change)
|
||||
groups = append(groups, resolutionReplayGroup{
|
||||
originalRecordIDs: []int{recordID},
|
||||
coveringRecordID: recordID,
|
||||
change: change,
|
||||
})
|
||||
reasonSet := make(map[string]struct{}, len(change.Reasons))
|
||||
for _, reason := range change.Reasons {
|
||||
reasonSet[reason] = struct{}{}
|
||||
}
|
||||
reasons = append(reasons, reasonSet)
|
||||
dependencySet := make(map[string]struct{}, len(snapshot.change.Dependencies))
|
||||
for _, dependency := range snapshot.change.Dependencies {
|
||||
dependencySet := make(map[string]struct{}, len(change.Dependencies))
|
||||
for _, dependency := range change.Dependencies {
|
||||
dependencySet[dependency.String()] = struct{}{}
|
||||
}
|
||||
dependencies = append(dependencies, dependencySet)
|
||||
@@ -503,66 +537,3 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
|
||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...)
|
||||
}
|
||||
|
||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
|
||||
seen := make(map[*document.Node]bool)
|
||||
count := 0
|
||||
var visit func(*document.Node) bool
|
||||
visit = func(node *document.Node) bool {
|
||||
if node == nil || seen[node] {
|
||||
return true
|
||||
}
|
||||
seen[node] = true
|
||||
count++
|
||||
if count > limit {
|
||||
return false
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if !visit(entry.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, child := range node.Sequence {
|
||||
if !visit(child) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
||||
return false
|
||||
}
|
||||
for _, application := range applications {
|
||||
if !visit(application.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if !visit(resolution.before) || !visit(resolution.after) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, expansion := range inherited {
|
||||
if expansion == nil {
|
||||
continue
|
||||
}
|
||||
if !visit(expansion.root) {
|
||||
return false
|
||||
}
|
||||
for _, operation := range expansion.operations {
|
||||
if !visit(operation.parent) || !visit(operation.effective) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, replacement := range expansion.replacements {
|
||||
if !visit(replacement.before) || !visit(replacement.after) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
282
internal/engine/history_budget_test.go
Normal file
282
internal/engine/history_budget_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type historyBudgetSimpleConfig struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
type historyBudgetRecord struct {
|
||||
Value string
|
||||
Credential Secret
|
||||
}
|
||||
|
||||
type historyBudgetLayeredConfig struct {
|
||||
Parent historyBudgetRecord
|
||||
Child historyBudgetRecord
|
||||
}
|
||||
|
||||
func retainedInspectionNodeCountForTest(inspection *Inspection) int {
|
||||
seen := make(map[*document.Node]struct{})
|
||||
model := inspection.history
|
||||
stack := []*document.Node{model.baseline, model.final, inspection.final}
|
||||
for _, payload := range model.sourcePayloads {
|
||||
if payload.kind == historySourceApplicationPayload {
|
||||
stack = append(stack, payload.application.value)
|
||||
}
|
||||
}
|
||||
for _, payload := range model.inheritancePayloads {
|
||||
for _, operation := range payload.operations {
|
||||
stack = append(stack, operation.parent, operation.effective)
|
||||
}
|
||||
}
|
||||
for _, payload := range model.cliPayloads {
|
||||
if payload.kind == historySourceApplicationPayload {
|
||||
stack = append(stack, payload.application.value)
|
||||
}
|
||||
}
|
||||
if model.preCLICheckpoint != nil {
|
||||
stack = append(stack, model.preCLICheckpoint.root)
|
||||
}
|
||||
if model.postCLICheckpoint != nil {
|
||||
stack = append(stack, model.postCLICheckpoint.root)
|
||||
}
|
||||
if model.resolutionPayload != nil {
|
||||
for _, snapshot := range model.resolutionPayload.snapshots {
|
||||
stack = append(stack, snapshot.before, snapshot.after)
|
||||
}
|
||||
}
|
||||
|
||||
for len(stack) != 0 {
|
||||
last := len(stack) - 1
|
||||
node := stack[last]
|
||||
stack = stack[:last]
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[node]; exists {
|
||||
continue
|
||||
}
|
||||
seen[node] = struct{}{}
|
||||
for _, entry := range node.Mapping {
|
||||
stack = append(stack, entry.Value)
|
||||
}
|
||||
stack = append(stack, node.Sequence...)
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
func TestLoadHistoryBudgetUsesExactRetainedNodeCount(t *testing.T) {
|
||||
load := func(historyLimit int, validations *int) (*Loaded[historyBudgetSimpleConfig], error) {
|
||||
return loadWithSecretLookupLimit(
|
||||
Bytes("main", []byte("value: retained\n")),
|
||||
testSecretBinding(),
|
||||
func(string) (string, bool) { return "", false },
|
||||
historyLimit,
|
||||
Options[historyBudgetSimpleConfig]{Definition: Definition[historyBudgetSimpleConfig]{
|
||||
Validate: func(*historyBudgetSimpleConfig, *Validation) { *validations++ },
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
var baselineValidations int
|
||||
baseline, err := load(10000, &baselineValidations)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline load: %v", err)
|
||||
}
|
||||
want := retainedInspectionNodeCountForTest(baseline.Inspection)
|
||||
if want == 0 {
|
||||
t.Fatal("independent retained-tree count was empty")
|
||||
}
|
||||
|
||||
var exactValidations int
|
||||
loaded, err := load(want, &exactValidations)
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
|
||||
}
|
||||
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
|
||||
t.Fatalf("retained node count = %d, want %d", got, want)
|
||||
}
|
||||
if exactValidations != 1 {
|
||||
t.Fatalf("validation calls at exact count = %d, want 1", exactValidations)
|
||||
}
|
||||
|
||||
var rejectedValidations int
|
||||
loaded, err = load(want-1, &rejectedValidations)
|
||||
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
|
||||
t.Fatalf("load below retained-node count: result=%v error=%v", loaded, err)
|
||||
}
|
||||
if rejectedValidations != 0 {
|
||||
t.Fatalf("validation ran before history rejection: %d calls", rejectedValidations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryNodeBudgetIncludesFinalInspectionProjection(t *testing.T) {
|
||||
final := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "value", Value: &document.Node{Kind: document.String, Text: "final"},
|
||||
}}}
|
||||
builder := newHistoryBuilder()
|
||||
model := builder.freeze()
|
||||
inspection := &Inspection{history: model, metadata: Metadata{historyModel: model}, final: final}
|
||||
|
||||
counter := newRetainedNodeCounter(2)
|
||||
admitted := addInspectionHistory(counter, inspection)
|
||||
if !admitted || counter.count != 2 {
|
||||
t.Fatalf("final inspection projection was not counted exactly: admitted=%t count=%d", admitted, counter.count)
|
||||
}
|
||||
counter = newRetainedNodeCounter(1)
|
||||
if addInspectionHistory(counter, inspection) {
|
||||
t.Fatal("final inspection projection did not consume the node budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredHistoryBudgetRejectsBeforeSecretsAndValidation(t *testing.T) {
|
||||
type callbackCounts struct {
|
||||
lookups int
|
||||
validations int
|
||||
}
|
||||
makeRequest := func(counts *callbackCounts) loadRequest[historyBudgetLayeredConfig] {
|
||||
return loadRequest[historyBudgetLayeredConfig]{
|
||||
main: loadInput{source: Bytes("main", []byte("child:\n extends: parent\n"))},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("underlay", []byte(
|
||||
"parent:\n value: inherited\n credential: HISTORY_BUDGET_SECRET\n",
|
||||
))}}}},
|
||||
overrides: []Override{{Path: Keys("child", "value"), Value: "cli", Flag: "--child-value"}},
|
||||
definition: Definition[historyBudgetLayeredConfig]{
|
||||
Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error {
|
||||
configuration.Child.Value += "-resolved"
|
||||
resolution.RecordNormalization(Keys("child", "value"), "normalize child")
|
||||
resolution.RecordDerivedDefault(Keys("child"), "record enclosing child state")
|
||||
return nil
|
||||
},
|
||||
Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ },
|
||||
},
|
||||
}
|
||||
}
|
||||
lookup := func(counts *callbackCounts) secretLookup {
|
||||
return func(name string) (string, bool) {
|
||||
counts.lookups++
|
||||
if name != "HISTORY_BUDGET_SECRET" {
|
||||
t.Errorf("secret lookup name = %q", name)
|
||||
}
|
||||
return "resolved-secret", true
|
||||
}
|
||||
}
|
||||
|
||||
baselineCounts := &callbackCounts{}
|
||||
baseline, err := loadGroupedWithSecretLookupLimit(makeRequest(baselineCounts), testSecretBinding(), lookup(baselineCounts), 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline grouped load: %v", err)
|
||||
}
|
||||
want := retainedInspectionNodeCountForTest(baseline.Inspection)
|
||||
if want == 0 {
|
||||
t.Fatal("independent retained-tree count was empty")
|
||||
}
|
||||
if baseline.Inspection.history == nil || baseline.Inspection.metadata.historyModel != baseline.Inspection.history || len(baseline.Inspection.history.inheritancePayloads) == 0 {
|
||||
t.Fatal("layered load did not publish its shared inheritance evidence owner")
|
||||
}
|
||||
resolution := baseline.Inspection.history.resolutionPayload
|
||||
if resolution == nil || len(resolution.snapshots) != 2 || len(resolution.replayGroups) == 0 {
|
||||
t.Fatalf("layered load did not retain overlapping original resolution evidence: %#v", resolution)
|
||||
}
|
||||
resolutionPaths := make(map[string]bool, len(resolution.snapshots))
|
||||
for _, snapshot := range resolution.snapshots {
|
||||
resolutionPaths[snapshot.change.Path.String()] = true
|
||||
}
|
||||
if !resolutionPaths["/child"] || !resolutionPaths["/child/value"] {
|
||||
t.Fatalf("layered load resolution snapshots have paths %v, want /child and /child/value", resolutionPaths)
|
||||
}
|
||||
group := resolution.replayGroups[0]
|
||||
groupRecords := make(map[int]bool, len(group.originalRecordIDs))
|
||||
for _, id := range group.originalRecordIDs {
|
||||
groupRecords[id] = true
|
||||
}
|
||||
if len(groupRecords) != 2 || !groupRecords[0] || !groupRecords[1] || group.change.Path.String() != "/child" {
|
||||
t.Fatalf("layered load did not group both overlapping original records: %#v", group)
|
||||
}
|
||||
exactCounts := &callbackCounts{}
|
||||
loaded, err := loadGroupedWithSecretLookupLimit(makeRequest(exactCounts), testSecretBinding(), lookup(exactCounts), want)
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("grouped load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
|
||||
}
|
||||
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
|
||||
t.Fatalf("grouped retained node count = %d, want %d", got, want)
|
||||
}
|
||||
if loaded.Config.Child.Value != "cli-resolved" || exactCounts.lookups != 1 || exactCounts.validations != 1 {
|
||||
t.Fatalf("grouped lifecycle result=%#v callbacks=%#v", loaded.Config, exactCounts)
|
||||
}
|
||||
|
||||
rejectedCounts := &callbackCounts{}
|
||||
loaded, err = loadGroupedWithSecretLookupLimit(makeRequest(rejectedCounts), testSecretBinding(), lookup(rejectedCounts), want-1)
|
||||
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
|
||||
t.Fatalf("grouped load below retained-node count: result=%v error=%v", loaded, err)
|
||||
}
|
||||
if rejectedCounts.lookups != 0 || rejectedCounts.validations != 0 {
|
||||
t.Fatalf("callbacks ran before history rejection: %#v", rejectedCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedInheritanceEvidenceSharesOwnerAndDetachesPublicViews(t *testing.T) {
|
||||
input := parseCompositionDocument(t, "inheritance", "parent: {value: shared}\nchild: {extends: parent}\n")
|
||||
expanded, diagnostics := expandInheritanceWithOrigins(input, nil, defaultProjectionLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
baseline := &document.Node{Kind: document.Mapping}
|
||||
builder := newHistoryBuilder()
|
||||
builder.observeBaseline(baseline)
|
||||
builder.setScratch(input)
|
||||
builder.observeInheritanceEvidence(expanded.operations)
|
||||
builder.observePreCLICheckpoint(expanded.root, expanded.origins)
|
||||
builder.observePostCLICheckpoint(expanded.root, expanded.origins)
|
||||
builder.prefixReady = true
|
||||
metadata := Metadata{historyBuilder: builder}
|
||||
resolved := resolvedLoad[historyBudgetSimpleConfig]{
|
||||
metadata: metadata, final: expanded.root,
|
||||
}
|
||||
counter := newRetainedNodeCounter(100)
|
||||
inspection, ok := prepareInspectionHistory(resolved, counter)
|
||||
if !ok {
|
||||
t.Fatal("preparation rejected the shared evidence owner")
|
||||
}
|
||||
if inspection.history == nil || inspection.metadata.historyModel != inspection.history || len(inspection.history.inheritancePayloads) != 1 {
|
||||
t.Fatalf("history owners = inspection %p, metadata %p, inheritance payloads %d", inspection.history, inspection.metadata.historyModel, len(inspection.history.inheritancePayloads))
|
||||
}
|
||||
path := Keys("child", "value")
|
||||
first := inspection.Metadata().History(path)
|
||||
if len(first) == 0 {
|
||||
t.Fatal("metadata projection omitted inherited history")
|
||||
}
|
||||
first[0].Origin.Locations[0].SourceID = "mutated metadata result"
|
||||
first[0].Dependencies[0].segments[0].key = "mutated metadata dependency"
|
||||
if explanation, found := inspection.Explain(path); !found || len(explanation.History) == 0 || explanation.History[0].Origin.Locations[0].SourceID == "mutated metadata result" {
|
||||
t.Fatalf("metadata result mutation leaked into explanation: %#v, %v", explanation, found)
|
||||
}
|
||||
explanation, _ := inspection.Explain(path)
|
||||
explanation.History[0].Origin.Locations[0].SourceID = "mutated explanation result"
|
||||
second := inspection.Metadata().History(path)
|
||||
if second[0].Origin.Locations[0].SourceID == "mutated explanation result" || second[0].Dependencies[0].String() == "/mutated metadata dependency" {
|
||||
t.Fatalf("explanation mutation leaked into metadata: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func hasHistoryLimitDiagnostic(t *testing.T, err error) bool {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
loadErr, ok := err.(*LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *LoadError", err)
|
||||
}
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
if diagnostic.Code == CodeHistoryLimitExceeded {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
165
internal/engine/history_cli.go
Normal file
165
internal/engine/history_cli.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type historyCheckpointPayload struct {
|
||||
root *document.Node
|
||||
origins map[string]Origin
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observePreCLICheckpoint(root *document.Node, origins map[string]Origin) {
|
||||
builder.observeTypedCheckpoint(historyPhasePreCLI, root, origins, "applied retained defaults and canonical typed decoding")
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observePostCLICheckpoint(root *document.Node, origins map[string]Origin) {
|
||||
builder.observeTypedCheckpoint(historyPhasePostCLI, root, origins, "applied the typed CLI assignment")
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeTypedCheckpoint(phase historyPhase, root *document.Node, origins map[string]Origin, reason string) {
|
||||
builder.requireOpen()
|
||||
if phase != historyPhasePreCLI && phase != historyPhasePostCLI {
|
||||
panic("configkit: typed checkpoint has an invalid history phase")
|
||||
}
|
||||
payload := builder.nextPayload(phase)
|
||||
checkpoint := historyCheckpointPayload{root: root, origins: cloneOriginIndex(origins)}
|
||||
switch phase {
|
||||
case historyPhasePreCLI:
|
||||
if builder.model.preCLICheckpoint != nil || payload.id != 0 {
|
||||
panic("configkit: pre-CLI checkpoint was already registered")
|
||||
}
|
||||
builder.model.preCLICheckpoint = &checkpoint
|
||||
case historyPhasePostCLI:
|
||||
if builder.model.postCLICheckpoint != nil || payload.id != 0 {
|
||||
panic("configkit: post-CLI checkpoint was already registered")
|
||||
}
|
||||
builder.model.postCLICheckpoint = &checkpoint
|
||||
}
|
||||
|
||||
beforeRoot := builder.scratch
|
||||
paths := unionCLIHistoryPaths(beforeRoot, root)
|
||||
lookup := buildDocumentLookup(beforeRoot, root)
|
||||
for _, path := range paths {
|
||||
beforeNode, before := documentAtPathWithLookup(beforeRoot, path, lookup)
|
||||
afterNode, after := documentAtPathWithLookup(root, path, lookup)
|
||||
sensitive := beforeNode != nil && beforeNode.Sensitive || afterNode != nil && afterNode.Sensitive
|
||||
builder.addObservation(historyObservation{
|
||||
path: path, phase: phase, payload: payload,
|
||||
reportedPath: path, role: historyRoleInventory,
|
||||
sensitive: sensitive, attributionRef: noHistoryAttribution,
|
||||
})
|
||||
if configurationValuesEqual(beforeNode, afterNode) {
|
||||
continue
|
||||
}
|
||||
attributionRef := noHistoryAttribution
|
||||
if after {
|
||||
origin, found := origins[path.String()]
|
||||
if !found {
|
||||
origin = Origin{Layer: LayerDefault}
|
||||
}
|
||||
origin.Operation = ChangeDecoding
|
||||
attributionRef = builder.addAttribution(origin, []string{reason}, nil)
|
||||
}
|
||||
builder.addObservation(historyObservation{
|
||||
path: path, phase: phase, payload: payload,
|
||||
reportedPath: path, role: historyRoleCheckpoint,
|
||||
beforePresent: before, afterPresent: after, sensitive: sensitive,
|
||||
attributionRef: attributionRef,
|
||||
})
|
||||
}
|
||||
builder.advanceScratch(document.Clone(root))
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeCLIEvidence(applications []historyApplication, records []overlayRecord) {
|
||||
builder.requireOpen()
|
||||
count := max(len(applications), len(records))
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
index := new(mappingIndex)
|
||||
for item := 0; item < count; item++ {
|
||||
var applicationRef historyPayloadRef
|
||||
hasApplication := false
|
||||
if item < len(applications) {
|
||||
application := applications[item]
|
||||
applicationRef = builder.addCLIPayload(&application, nil)
|
||||
hasApplication = true
|
||||
walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
|
||||
origin := sourceApplicationOrigin(application, node)
|
||||
attribution := builder.addAttribution(origin, nil, nil)
|
||||
builder.addObservation(historyObservation{
|
||||
path: effective, phase: historyPhaseCLI, payload: applicationRef,
|
||||
reportedPath: effective, role: historyRoleContribution,
|
||||
afterPresent: true, sensitive: node.Sensitive,
|
||||
attributionRef: attribution,
|
||||
})
|
||||
})
|
||||
applyHistoryApplication(&builder.scratch, application, index)
|
||||
}
|
||||
if item < len(records) {
|
||||
record := records[item]
|
||||
payload := builder.addCLIPayload(nil, &record)
|
||||
if hasApplication {
|
||||
owned := &builder.model.cliPayloads[payload.id]
|
||||
owned.applicationRef = applicationRef
|
||||
owned.hasApplicationRef = true
|
||||
}
|
||||
attribution := builder.addAttribution(record.change.Origin, nil, nil)
|
||||
for _, displaced := range record.displaced {
|
||||
builder.addObservation(historyObservation{
|
||||
path: displaced.path, phase: historyPhaseCLI, payload: payload,
|
||||
reportedPath: record.change.Path, role: historyRoleOperation,
|
||||
beforePresent: true, attributionRef: attribution,
|
||||
})
|
||||
}
|
||||
for _, introduced := range record.introduced {
|
||||
builder.addObservation(historyObservation{
|
||||
path: introduced.path, phase: historyPhaseCLI, payload: payload,
|
||||
reportedPath: record.change.Path, role: historyRoleOperation,
|
||||
afterPresent: true, sensitive: introduced.sensitive,
|
||||
attributionRef: attribution,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addCLIPayload(application *historyApplication, record *overlayRecord) historyPayloadRef {
|
||||
payload := builder.nextPayload(historyPhaseCLI)
|
||||
if payload.id != len(builder.model.cliPayloads) {
|
||||
panic("configkit: CLI payload identifiers are out of order")
|
||||
}
|
||||
owned := historySourcePayload{}
|
||||
switch {
|
||||
case application != nil:
|
||||
owned.kind = historySourceApplicationPayload
|
||||
owned.application = cloneHistoryApplications([]historyApplication{*application})[0]
|
||||
case record != nil:
|
||||
owned.kind = historySourceOverlayPayload
|
||||
owned.overlay = cloneOverlayRecords([]overlayRecord{*record})[0]
|
||||
default:
|
||||
panic("configkit: CLI payload requires an application or operation")
|
||||
}
|
||||
builder.model.cliPayloads = append(builder.model.cliPayloads, owned)
|
||||
return payload
|
||||
}
|
||||
|
||||
func unionCLIHistoryPaths(roots ...*document.Node) []Path {
|
||||
var paths []Path
|
||||
seen := make(map[string][]Path)
|
||||
for _, root := range roots {
|
||||
if root == nil {
|
||||
continue
|
||||
}
|
||||
walkDocument(root, Path{}, func(path Path, _ *document.Node) {
|
||||
for _, existing := range seen[path.String()] {
|
||||
if sameConcreteHistoryPath(existing, path) {
|
||||
return
|
||||
}
|
||||
}
|
||||
owned := path.clone()
|
||||
paths = append(paths, owned)
|
||||
seen[path.String()] = append(seen[path.String()], owned)
|
||||
})
|
||||
}
|
||||
return paths
|
||||
}
|
||||
186
internal/engine/history_cli_test.go
Normal file
186
internal/engine/history_cli_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestCLIOverrideRecordsTrackConcreteShapeChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
beforeText string
|
||||
incomingText string
|
||||
oldPath Path
|
||||
introducedPath Path
|
||||
}{
|
||||
{
|
||||
name: "mapping to sequence",
|
||||
beforeText: "target: {'0': {old: true}}\n",
|
||||
incomingText: "target: [{new: true}]\n",
|
||||
oldPath: Keys("target", "0", "old"),
|
||||
introducedPath: Keys("target").Index(0).Key("new"),
|
||||
},
|
||||
{
|
||||
name: "sequence to mapping",
|
||||
beforeText: "target: [{old: true}]\n",
|
||||
incomingText: "target: {'0': {new: true}}\n",
|
||||
oldPath: Keys("target").Index(0).Key("old"),
|
||||
introducedPath: Keys("target", "0", "new"),
|
||||
},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
before := parseDocumentForDecode(t, test.beforeText)
|
||||
incomingRoot := parseDocumentForDecode(t, test.incomingText)
|
||||
setOverrideTestDocumentSource(incomingRoot, "cli-source")
|
||||
incoming, _ := documentAtPath(incomingRoot, Keys("target"))
|
||||
origins := make(map[string]Origin)
|
||||
collectOverlayOrigins(origins, before, Path{}, LayerMain)
|
||||
records := overrideMetadataRecords(decodedConfiguration{final: before, origins: origins}, []preparedOverride{{
|
||||
path: Keys("target"), value: incoming, source: SourceInfo{ID: "cli-source"},
|
||||
}})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("override records = %#v", records)
|
||||
}
|
||||
record := records[0]
|
||||
if !containsConcreteDisplacement(record.displaced, test.oldPath) {
|
||||
t.Fatalf("displaced shape %s missing from %#v", test.oldPath, record.displaced)
|
||||
}
|
||||
introduced := findHistoryPathOrigin(record.introduced, test.introducedPath)
|
||||
if introduced == nil || introduced.origin.Layer != LayerCLI || len(introduced.origin.Locations) != 1 || introduced.origin.Locations[0].SourceID != "cli-source" {
|
||||
t.Fatalf("introduced shape %s has no CLI attribution: %#v", test.introducedPath, record.introduced)
|
||||
}
|
||||
application := historyApplication{
|
||||
layer: LayerCLI, path: Keys("target"), value: incoming,
|
||||
origin: overlayOrigin(LayerCLI, document.Location{SourceID: "cli-source"}),
|
||||
}
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(before)
|
||||
builder.observeCLIEvidence([]historyApplication{application}, records)
|
||||
model := builder.freeze()
|
||||
oldSelections := model.selectHistory(test.oldPath)
|
||||
if len(oldSelections) != 1 || oldSelections[0].role != historyRoleOperation || !oldSelections[0].before || oldSelections[0].after {
|
||||
t.Fatalf("displaced CLI shape %s = %#v", test.oldPath, oldSelections)
|
||||
}
|
||||
introducedSelections := model.selectHistory(test.introducedPath)
|
||||
introducedOperation := false
|
||||
for _, selection := range introducedSelections {
|
||||
introducedOperation = introducedOperation || selection.role == historyRoleOperation && !selection.before && selection.after
|
||||
}
|
||||
if !introducedOperation {
|
||||
t.Fatalf("introduced CLI shape %s selections = %#v", test.introducedPath, introducedSelections)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIHistoryEvidenceKeepsCheckpointsAndConditionalReconciliation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
structural := parseDocumentForDecode(t, "count: 7\nsettings: {a: 1, b: 2}\nitems: {old: value}\n")
|
||||
preCLI := parseDocumentForDecode(t, "count: '7'\nsettings: {a: 1, b: 2}\nitems: {old: value}\ndefaulted: 9\n")
|
||||
preOrigins := map[string]Origin{
|
||||
Keys("count").String(): {Layer: LayerMain, Locations: []Location{{SourceID: "input"}}},
|
||||
Keys("defaulted").String(): {Layer: LayerDefault},
|
||||
}
|
||||
countRoot := parseDocumentForDecode(t, "count: 7\n")
|
||||
settingsRoot := parseDocumentForDecode(t, "settings: {a: 1}\n")
|
||||
itemsRoot := parseDocumentForDecode(t, "items: []\n")
|
||||
setOverrideTestDocumentSource(countRoot, "cli-count")
|
||||
setOverrideTestDocumentSource(settingsRoot, "cli-settings")
|
||||
setOverrideTestDocumentSource(itemsRoot, "cli-items")
|
||||
countValue, _ := documentAtPath(countRoot, Keys("count"))
|
||||
settingsValue, _ := documentAtPath(settingsRoot, Keys("settings"))
|
||||
itemsValue, _ := documentAtPath(itemsRoot, Keys("items"))
|
||||
overrides := []preparedOverride{
|
||||
{path: Keys("count"), value: countValue, source: SourceInfo{ID: "cli-count"}},
|
||||
{path: Keys("settings"), value: settingsValue, source: SourceInfo{ID: "cli-settings"}},
|
||||
{path: Keys("items"), value: itemsValue, source: SourceInfo{ID: "cli-items"}},
|
||||
}
|
||||
records := overrideMetadataRecords(decodedConfiguration{final: preCLI, origins: preOrigins}, overrides)
|
||||
applications := make([]historyApplication, len(overrides))
|
||||
for index, override := range overrides {
|
||||
applications[index] = historyApplication{
|
||||
layer: LayerCLI, path: override.path, value: override.value,
|
||||
origin: overlayOrigin(LayerCLI, document.Location{SourceID: override.source.ID}),
|
||||
}
|
||||
}
|
||||
postCLI := parseDocumentForDecode(t, "count: '7'\nsettings: {a: 1, b: 2}\nitems: []\ndefaulted: 9\n")
|
||||
postOrigins := map[string]Origin{
|
||||
Keys("count").String(): {Layer: LayerCLI, Locations: []Location{{SourceID: "cli-count"}}},
|
||||
Keys("defaulted").String(): {Layer: LayerDefault},
|
||||
}
|
||||
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(structural)
|
||||
builder.observePreCLICheckpoint(preCLI, preOrigins)
|
||||
builder.observeCLIEvidence(applications, records)
|
||||
builder.observePostCLICheckpoint(postCLI, postOrigins)
|
||||
model := builder.freeze()
|
||||
|
||||
if model.preCLICheckpoint == nil || model.postCLICheckpoint == nil ||
|
||||
len(model.cliPayloads) != 6 {
|
||||
t.Fatalf("checkpoint/CLI payload ownership = pre:%#v post:%#v CLI:%d", model.preCLICheckpoint, model.postCLICheckpoint, len(model.cliPayloads))
|
||||
}
|
||||
preValue, _ := documentAtPath(model.preCLICheckpoint.root, Keys("count"))
|
||||
postValue, _ := documentAtPath(model.postCLICheckpoint.root, Keys("count"))
|
||||
if preValue.Kind != document.String || preValue.Text != "7" || postValue.Kind != document.String || postValue.Text != "7" {
|
||||
t.Fatalf("typed checkpoint values = %#v / %#v", preValue, postValue)
|
||||
}
|
||||
|
||||
count := model.selectHistory(Keys("count"))
|
||||
var sawPreDecoding, sawEqualCLI, sawPostDecoding bool
|
||||
for _, selection := range count {
|
||||
switch selection.phase {
|
||||
case historyPhasePreCLI:
|
||||
sawPreDecoding = selection.role == historyRoleCheckpoint && selection.before && selection.after &&
|
||||
selection.attribution.origin.Operation == ChangeDecoding && len(selection.attribution.reasons) != 0
|
||||
case historyPhaseCLI:
|
||||
sawEqualCLI = sawEqualCLI || selection.role == historyRoleOperation && selection.after
|
||||
case historyPhasePostCLI:
|
||||
sawPostDecoding = selection.role == historyRoleCheckpoint && selection.before && selection.after &&
|
||||
selection.attribution.origin.Layer == LayerCLI && selection.attribution.origin.Operation == ChangeDecoding
|
||||
}
|
||||
}
|
||||
if !sawPreDecoding || !sawEqualCLI || !sawPostDecoding {
|
||||
t.Fatalf("checkpoint and equal CLI selections = %#v", count)
|
||||
}
|
||||
if metadataHistoryRole(historyRoleCheckpoint) || !metadataHistoryRole(historyRoleOperation) {
|
||||
t.Fatal("typed reconciliation checkpoint leaked into metadata history roles")
|
||||
}
|
||||
|
||||
defaulted := model.selectHistory(Keys("defaulted"))
|
||||
if len(defaulted) != 1 || defaulted[0].phase != historyPhasePreCLI || defaulted[0].before || !defaulted[0].after ||
|
||||
defaulted[0].attribution.origin.Layer != LayerDefault || len(defaulted[0].attribution.reasons) == 0 {
|
||||
t.Fatalf("retained default checkpoint = %#v", defaulted)
|
||||
}
|
||||
removed := model.selectHistory(Keys("items", "old"))
|
||||
if len(removed) != 1 || removed[0].phase != historyPhaseCLI || removed[0].role != historyRoleOperation || !removed[0].before || removed[0].after {
|
||||
t.Fatalf("whole-sequence CLI removal = %#v", removed)
|
||||
}
|
||||
untouched := model.selectHistory(Keys("settings", "b"))
|
||||
if len(untouched) != 0 {
|
||||
t.Fatalf("partial mapping CLI override attributed an untouched key: %#v", untouched)
|
||||
}
|
||||
}
|
||||
|
||||
func containsConcreteDisplacement(displaced []displacedDefinition, path Path) bool {
|
||||
for _, candidate := range displaced {
|
||||
if sameConcreteHistoryPath(candidate.path, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findHistoryPathOrigin(introduced []historyPathOrigin, path Path) *historyPathOrigin {
|
||||
for index := range introduced {
|
||||
if sameConcreteHistoryPath(introduced[index].path, path) {
|
||||
return &introduced[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
285
internal/engine/history_inheritance.go
Normal file
285
internal/engine/history_inheritance.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type historyInheritancePayload struct {
|
||||
operations []inheritanceOperation
|
||||
}
|
||||
|
||||
type inheritanceEvidenceKind uint8
|
||||
|
||||
const (
|
||||
inheritanceEvidenceControl inheritanceEvidenceKind = iota
|
||||
inheritanceEvidenceParent
|
||||
inheritanceEvidenceChild
|
||||
inheritanceEvidenceDisplaced
|
||||
inheritanceEvidenceRemoval
|
||||
)
|
||||
|
||||
type inheritanceAttributionSelection struct {
|
||||
kind inheritanceEvidenceKind
|
||||
path Path
|
||||
origin Origin
|
||||
dependencies []Path
|
||||
}
|
||||
|
||||
type inheritanceShapeFacts struct {
|
||||
child bool
|
||||
parent bool
|
||||
effective bool
|
||||
}
|
||||
|
||||
// observeInheritanceEvidence owns the ordered inheritance operation list and
|
||||
// records its concrete before/after shapes against the builder's one scratch
|
||||
// projection. Parent and effective-child trees alone cannot establish which
|
||||
// concrete paths an operation displaced from its child or enclosing tree.
|
||||
func (builder *historyBuilder) observeInheritanceEvidence(operations []inheritanceOperation) {
|
||||
builder.requireOpen()
|
||||
if len(operations) == 0 {
|
||||
return
|
||||
}
|
||||
payload := builder.nextPayload(historyPhaseInheritance)
|
||||
if payload.id != len(builder.model.inheritancePayloads) {
|
||||
panic("configkit: inheritance payload identifiers are out of order")
|
||||
}
|
||||
owned := copyInheritanceOperations(operations, false)
|
||||
builder.model.inheritancePayloads = append(builder.model.inheritancePayloads, historyInheritancePayload{operations: owned})
|
||||
|
||||
for operationIndex, operation := range owned {
|
||||
if operation.control != nil {
|
||||
builder.observeInheritanceControl(payload, operationIndex, operation)
|
||||
continue
|
||||
}
|
||||
if operation.transfer == nil {
|
||||
continue
|
||||
}
|
||||
builder.observeInheritanceTransfer(payload, operationIndex, operation)
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeInheritanceControl(payload historyPayloadRef, operationIndex int, operation inheritanceOperation) {
|
||||
control := operation.control
|
||||
if control == nil {
|
||||
return
|
||||
}
|
||||
node, before := builder.scratchAt(control.path)
|
||||
selections := resolveInheritanceAttribution(operation, control.path)
|
||||
var attributionRef historyAttributionRef = noHistoryAttribution
|
||||
var sensitive bool
|
||||
if before && node != nil {
|
||||
sensitive = node.Sensitive
|
||||
}
|
||||
if len(selections) != 0 {
|
||||
selection := selections[0]
|
||||
attributionRef = builder.addAttribution(selection.origin, nil, selection.dependencies)
|
||||
}
|
||||
ref := payload
|
||||
ref.substep = operationIndex * 2
|
||||
builder.addObservation(historyObservation{
|
||||
path: control.path, phase: historyPhaseInheritance, payload: ref,
|
||||
reportedPath: control.path, role: historyRoleControl,
|
||||
beforePresent: before, sensitive: sensitive,
|
||||
attributionRef: attributionRef,
|
||||
})
|
||||
replaceHistoryPath(&builder.scratch, control.path, nil)
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeInheritanceTransfer(payload historyPayloadRef, operationIndex int, operation inheritanceOperation) {
|
||||
transfer := operation.transfer
|
||||
if transfer == nil {
|
||||
return
|
||||
}
|
||||
beforeChild, _ := documentAtPath(builder.scratch, transfer.to)
|
||||
parentPaths := inheritanceTreePaths(operation.parent, transfer.to)
|
||||
beforePaths := inheritanceTreePaths(beforeChild, transfer.to)
|
||||
parentSide := unionInheritancePaths(beforePaths, parentPaths)
|
||||
parentRef := payload
|
||||
parentRef.substep = operationIndex * 2
|
||||
lookup := buildDocumentLookup(beforeChild, operation.parent)
|
||||
for _, path := range parentSide {
|
||||
if inheritanceOperationIdentity(operation, path) {
|
||||
continue
|
||||
}
|
||||
beforeNode, before := documentAtInheritancePath(beforeChild, transfer.to, path, lookup)
|
||||
parentNode, after := documentAtInheritancePath(operation.parent, transfer.to, path, lookup)
|
||||
selection := selectInheritanceEvidence(resolveInheritanceAttribution(operation, path, inheritanceShapeFacts{
|
||||
child: before, parent: after,
|
||||
}), inheritanceEvidenceParent, inheritanceEvidenceDisplaced)
|
||||
builder.addInheritanceObservation(parentRef, transfer.to, path, before, after, beforeNode, parentNode, selection)
|
||||
}
|
||||
|
||||
parentWorking := document.Clone(operation.parent)
|
||||
if parentWorking != nil {
|
||||
replaceHistoryPath(&builder.scratch, transfer.to, parentWorking)
|
||||
for _, field := range operation.identityFields {
|
||||
identity, found := documentAtPath(operation.effective, Keys(field))
|
||||
if found {
|
||||
replaceHistoryPath(&builder.scratch, transfer.to.Key(field), document.Clone(identity))
|
||||
}
|
||||
}
|
||||
}
|
||||
parentState, _ := documentAtPath(builder.scratch, transfer.to)
|
||||
effectivePaths := inheritanceTreePaths(operation.effective, transfer.to)
|
||||
parentStatePaths := inheritanceTreePaths(parentState, transfer.to)
|
||||
childSide := unionInheritancePaths(parentStatePaths, effectivePaths)
|
||||
childRef := payload
|
||||
childRef.substep = operationIndex*2 + 1
|
||||
lookup = buildDocumentLookup(parentState, operation.effective)
|
||||
for _, path := range childSide {
|
||||
if inheritanceOperationIdentity(operation, path) {
|
||||
continue
|
||||
}
|
||||
parentNode, before := documentAtInheritancePath(parentState, transfer.to, path, lookup)
|
||||
effectiveNode, after := documentAtInheritancePath(operation.effective, transfer.to, path, lookup)
|
||||
selection := selectInheritanceEvidence(resolveInheritanceAttribution(operation, path, inheritanceShapeFacts{
|
||||
parent: before, effective: after,
|
||||
}), inheritanceEvidenceChild, inheritanceEvidenceRemoval, inheritanceEvidenceDisplaced)
|
||||
builder.addInheritanceObservation(childRef, transfer.to, path, before, after, parentNode, effectiveNode, selection)
|
||||
}
|
||||
|
||||
if operation.effective != nil {
|
||||
replaceHistoryPath(&builder.scratch, transfer.to, document.Clone(operation.effective))
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addInheritanceObservation(payload historyPayloadRef, reportedPath, path Path, before, after bool, beforeNode, afterNode *document.Node, selection *inheritanceAttributionSelection) {
|
||||
ref := noHistoryAttribution
|
||||
if selection != nil {
|
||||
ref = builder.addAttribution(selection.origin, nil, selection.dependencies)
|
||||
}
|
||||
sensitive := beforeNode != nil && beforeNode.Sensitive || afterNode != nil && afterNode.Sensitive
|
||||
builder.addObservation(historyObservation{
|
||||
path: path, phase: historyPhaseInheritance, payload: payload,
|
||||
reportedPath: reportedPath, role: historyRoleOperation,
|
||||
beforePresent: before, afterPresent: after, sensitive: sensitive,
|
||||
attributionRef: ref,
|
||||
})
|
||||
}
|
||||
|
||||
func selectInheritanceEvidence(selections []inheritanceAttributionSelection, preferred inheritanceEvidenceKind, fallbacks ...inheritanceEvidenceKind) *inheritanceAttributionSelection {
|
||||
for index := range selections {
|
||||
if selections[index].kind == preferred {
|
||||
return &selections[index]
|
||||
}
|
||||
}
|
||||
for _, fallback := range fallbacks {
|
||||
for index := range selections {
|
||||
if selections[index].kind == fallback {
|
||||
return &selections[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inheritanceTreePaths(root *document.Node, destination Path) []Path {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
var paths []Path
|
||||
walkDocument(root, Path{}, func(local Path, _ *document.Node) {
|
||||
paths = append(paths, appendInheritancePath(destination, local))
|
||||
})
|
||||
return paths
|
||||
}
|
||||
|
||||
func unionInheritancePaths(groups ...[]Path) []Path {
|
||||
var paths []Path
|
||||
seen := make(map[string][]Path)
|
||||
for _, group := range groups {
|
||||
for _, path := range group {
|
||||
found := false
|
||||
for _, existing := range seen[path.String()] {
|
||||
if sameConcreteHistoryPath(existing, path) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
owned := path.clone()
|
||||
paths = append(paths, owned)
|
||||
seen[path.String()] = append(seen[path.String()], owned)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func documentAtInheritancePath(root *document.Node, rootPath, path Path, lookup *documentLookup) (*document.Node, bool) {
|
||||
relative, within := relativeHistoryPath(rootPath, path)
|
||||
if !within {
|
||||
return nil, false
|
||||
}
|
||||
return documentAtPathWithLookup(root, relative, lookup)
|
||||
}
|
||||
|
||||
func resolveInheritanceAttribution(operation inheritanceOperation, destination Path, shapes ...inheritanceShapeFacts) []inheritanceAttributionSelection {
|
||||
if operation.control != nil {
|
||||
if !pathsIntersect(operation.control.path, destination) {
|
||||
return nil
|
||||
}
|
||||
origin := cloneOrigin(operation.controlOrigin)
|
||||
origin.Operation = ChangeInheritance
|
||||
origin.Inheritance = projectInheritanceSteps(origin)
|
||||
return []inheritanceAttributionSelection{{
|
||||
kind: inheritanceEvidenceControl, path: operation.control.path.clone(), origin: origin,
|
||||
dependencies: []Path{operation.control.path.clone()},
|
||||
}}
|
||||
}
|
||||
if operation.transfer == nil || !pathsIntersect(operation.transfer.to, destination) || inheritanceOperationIdentity(operation, destination) {
|
||||
return nil
|
||||
}
|
||||
path := destination
|
||||
if pathContains(destination, operation.transfer.to) {
|
||||
path = operation.transfer.to
|
||||
}
|
||||
var selections []inheritanceAttributionSelection
|
||||
parent, parentFound := transferredOperationOrigin(operation, path)
|
||||
effective, effectiveFound := operation.effectiveOrigins[path.String()]
|
||||
child, childFound := operation.childOrigins[path.String()]
|
||||
if len(shapes) != 0 {
|
||||
parentFound = parentFound && shapes[0].parent
|
||||
effectiveFound = effectiveFound && shapes[0].effective
|
||||
childFound = childFound && shapes[0].child
|
||||
}
|
||||
if parentFound {
|
||||
parent.Operation = ChangeInheritance
|
||||
parent.Inheritance = projectInheritanceSteps(parent)
|
||||
selections = append(selections, inheritanceAttributionSelection{
|
||||
kind: inheritanceEvidenceParent, path: path.clone(), origin: parent,
|
||||
dependencies: []Path{operation.transfer.from.clone(), operation.transfer.reference.clone()},
|
||||
})
|
||||
}
|
||||
if effectiveFound {
|
||||
effective = cloneOrigin(effective)
|
||||
effective.Operation = ChangeInheritance
|
||||
}
|
||||
if effectiveFound && (!parentFound || !originContainedBy(effective, parent)) {
|
||||
effective.Inheritance = projectInheritanceSteps(effective)
|
||||
selections = append(selections, inheritanceAttributionSelection{
|
||||
kind: inheritanceEvidenceChild, path: path.clone(), origin: effective,
|
||||
dependencies: []Path{operation.transfer.reference.clone()},
|
||||
})
|
||||
} else if parentFound && !effectiveFound {
|
||||
removed, found := operation.effectiveOrigins[operation.transfer.to.String()]
|
||||
if found {
|
||||
removed = cloneOrigin(removed)
|
||||
removed.Operation = ChangeInheritance
|
||||
removed.Inheritance = projectInheritanceSteps(removed)
|
||||
selections = append(selections, inheritanceAttributionSelection{
|
||||
kind: inheritanceEvidenceRemoval, path: operation.transfer.to.clone(), origin: removed,
|
||||
dependencies: []Path{operation.transfer.reference.clone()},
|
||||
})
|
||||
}
|
||||
}
|
||||
if childFound && !parentFound {
|
||||
child = cloneOrigin(child)
|
||||
child.Operation = ChangeInheritance
|
||||
child.Inheritance = projectInheritanceSteps(child)
|
||||
selections = append(selections, inheritanceAttributionSelection{
|
||||
kind: inheritanceEvidenceDisplaced, path: path.clone(), origin: child,
|
||||
dependencies: []Path{operation.transfer.reference.clone()},
|
||||
})
|
||||
}
|
||||
return selections
|
||||
}
|
||||
148
internal/engine/history_inheritance_test.go
Normal file
148
internal/engine/history_inheritance_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInheritanceEvidenceUsesOrderedPayloadAndConcreteTransferShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
controlPath := Keys("child", inheritanceKey)
|
||||
transfer := inheritanceTransfer{
|
||||
from: Keys("parent"), to: Keys("child"), reference: Keys("parent"),
|
||||
locations: []Location{{SourceID: "directive"}},
|
||||
}
|
||||
parent := parseDocumentForDecode(t, "id: parent\nbranch: {deep: {leaf: inherited}}\n")
|
||||
effective := parseDocumentForDecode(t, "id: child\nbranch: replaced\n")
|
||||
root := parseDocumentForDecode(t, "parent: {unused: true}\nchild: {id: child, extends: parent, branch: {local: stale}}\n")
|
||||
parentOrigins := map[string]Origin{
|
||||
Keys("parent", "branch").String(): {Layer: LayerMain, Locations: []Location{{SourceID: "parent"}}},
|
||||
Keys("parent", "branch", "deep").String(): {Layer: LayerMain, Locations: []Location{{SourceID: "parent"}}},
|
||||
Keys("parent", "branch", "deep", "leaf").String(): {Layer: LayerMain, Locations: []Location{{SourceID: "parent"}}},
|
||||
}
|
||||
effectiveOrigins := map[string]Origin{
|
||||
Keys("child", "branch").String(): {Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}},
|
||||
}
|
||||
childOrigins := map[string]Origin{
|
||||
Keys("child", "id").String(): {Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}},
|
||||
Keys("child", "branch").String(): {Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}},
|
||||
Keys("child", "branch", "local").String(): {Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}},
|
||||
}
|
||||
operation := inheritanceOperation{
|
||||
transfer: &transfer, parent: parent, effective: effective,
|
||||
parentOrigins: parentOrigins, effectiveOrigins: effectiveOrigins,
|
||||
childOrigins: childOrigins, identityFields: []string{"id"},
|
||||
}
|
||||
controlOrigin := Origin{Layer: LayerOverlay, Locations: []Location{{SourceID: "directive"}}}
|
||||
controlOperation := inheritanceOperation{
|
||||
control: &inheritanceControl{path: controlPath}, controlOrigin: controlOrigin,
|
||||
}
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(root)
|
||||
builder.observeInheritanceEvidence([]inheritanceOperation{controlOperation, operation})
|
||||
model := builder.freeze()
|
||||
|
||||
if len(model.inheritancePayloads) != 1 || len(model.inheritancePayloads[0].operations) != 2 {
|
||||
t.Fatalf("ordered inheritance payload = %#v", model.inheritancePayloads)
|
||||
}
|
||||
controlSelections := model.selectHistory(controlPath)
|
||||
if len(controlSelections) != 1 || controlSelections[0].role != historyRoleControl || !controlSelections[0].before || controlSelections[0].after ||
|
||||
!controlSelections[0].hasAttribution || len(controlSelections[0].attribution.dependencies) != 1 || controlSelections[0].attribution.dependencies[0].String() != controlPath.String() {
|
||||
t.Fatalf("consumed control selection = %#v", controlSelections)
|
||||
}
|
||||
|
||||
inheritedPath := Keys("child", "branch", "deep", "leaf")
|
||||
inherited := model.selectHistory(inheritedPath)
|
||||
if len(inherited) != 2 || inherited[0].phase != historyPhaseInheritance || inherited[0].payload.id != 0 || inherited[0].payload.substep != 2 ||
|
||||
inherited[0].before || !inherited[0].after || !inherited[0].hasAttribution || inherited[0].attribution.origin.Locations[0].SourceID != "parent" ||
|
||||
len(inherited[0].attribution.dependencies) != 2 || inherited[1].payload.substep != 3 || !inherited[1].before || inherited[1].after {
|
||||
t.Fatalf("parent transfer selection = %#v", inherited)
|
||||
}
|
||||
|
||||
displacedPath := Keys("child", "branch", "local")
|
||||
displaced := model.selectHistory(displacedPath)
|
||||
if len(displaced) != 1 || displaced[0].payload.substep != 2 || !displaced[0].before || displaced[0].after ||
|
||||
displaced[0].attribution.origin.Locations[0].SourceID != "child" {
|
||||
t.Fatalf("pre-transfer child shape was not retained: %#v", displaced)
|
||||
}
|
||||
if builder.scratch != nil {
|
||||
t.Fatal("frozen builder unexpectedly retained scratch")
|
||||
}
|
||||
if identity := model.selectHistory(Keys("child", "id")); len(identity) != 0 {
|
||||
t.Fatalf("child identity received inheritance evidence: %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritanceModelSharesProjectionAttribution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
from, to, reference := Keys("parents", "base"), Keys("children", "item"), Keys("parents", "base")
|
||||
path := to.Key("nested").Key("value")
|
||||
parent := Origin{Layer: LayerMain, Locations: []Location{{SourceID: "base"}}}
|
||||
child := Origin{Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}}
|
||||
parentDocument := parseDocumentForDecode(t, "nested: {value: parent}\n")
|
||||
effectiveDocument := parseDocumentForDecode(t, "nested: {value: child}\n")
|
||||
operation := inheritanceOperation{
|
||||
transfer: &inheritanceTransfer{from: from, to: to, reference: reference},
|
||||
parent: parentDocument,
|
||||
effective: effectiveDocument,
|
||||
parentOrigins: map[string]Origin{from.Key("nested").Key("value").String(): parent},
|
||||
effectiveOrigins: map[string]Origin{path.String(): child},
|
||||
}
|
||||
root := parseDocumentForDecode(t, "children: {item: {nested: {value: old}}}\n")
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(root)
|
||||
builder.observeInheritanceEvidence([]inheritanceOperation{operation})
|
||||
model := builder.freeze()
|
||||
selections := model.selectHistory(path)
|
||||
if len(selections) != 2 || selections[0].role != historyRoleOperation || selections[1].role != historyRoleOperation {
|
||||
t.Fatalf("model inheritance selections = %#v", selections)
|
||||
}
|
||||
if selections[0].reportedPath.String() != to.String() || selections[1].reportedPath.String() != to.String() ||
|
||||
selections[0].attribution.origin.Operation != ChangeInheritance || selections[1].attribution.origin.Operation != ChangeInheritance {
|
||||
t.Fatalf("parent and child attributions did not share the model owner: %#v", selections)
|
||||
}
|
||||
if selections[0].attribution.origin.Locations[0].SourceID != "base" ||
|
||||
selections[1].attribution.origin.Locations[0].SourceID != "child" ||
|
||||
len(selections[0].attribution.dependencies) != 2 || selections[0].attribution.dependencies[0].String() != from.String() ||
|
||||
selections[0].attribution.dependencies[1].String() != reference.String() ||
|
||||
len(selections[1].attribution.dependencies) != 1 || selections[1].attribution.dependencies[0].String() != reference.String() {
|
||||
t.Fatalf("model attribution dependencies or origins differ: %#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInheritanceEvidenceSeparatesCollidingKeyAndIndexShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
from, to := Keys("parent"), Keys("child")
|
||||
transfer := inheritanceTransfer{from: from, to: to, reference: from}
|
||||
parent := parseDocumentForDecode(t, "items: [{value: inherited}]\n")
|
||||
effective := parseDocumentForDecode(t, "items: {'0': {value: child}}\n")
|
||||
root := parseDocumentForDecode(t, "child: {items: {'0': {value: old}}}\n")
|
||||
parentPath := from.Key("items").Index(0).Key("value")
|
||||
childPath := to.Key("items").Key("0").Key("value")
|
||||
operation := inheritanceOperation{
|
||||
transfer: &transfer, parent: parent, effective: effective,
|
||||
parentOrigins: map[string]Origin{parentPath.String(): {Layer: LayerMain, Locations: []Location{{SourceID: "parent"}}}},
|
||||
effectiveOrigins: map[string]Origin{childPath.String(): {Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}}},
|
||||
childOrigins: map[string]Origin{childPath.String(): {Layer: LayerOverlay, Locations: []Location{{SourceID: "child"}}}},
|
||||
}
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(root)
|
||||
builder.observeInheritanceEvidence([]inheritanceOperation{operation})
|
||||
model := builder.freeze()
|
||||
|
||||
keyPath := to.Key("items").Key("0").Key("value")
|
||||
keySelections := model.selectHistory(keyPath)
|
||||
if len(keySelections) != 2 || keySelections[0].payload.substep != 0 || !keySelections[0].before || keySelections[0].after ||
|
||||
keySelections[0].attribution.origin.Locations[0].SourceID != "child" || keySelections[1].payload.substep != 1 ||
|
||||
keySelections[1].before || !keySelections[1].after || keySelections[1].attribution.origin.Locations[0].SourceID != "child" {
|
||||
t.Fatalf("mapping-key inheritance selections = %#v", keySelections)
|
||||
}
|
||||
|
||||
indexPath := to.Key("items").Index(0).Key("value")
|
||||
indexSelections := model.selectHistory(indexPath)
|
||||
if len(indexSelections) != 2 || indexSelections[0].payload.substep != 0 || indexSelections[0].before || !indexSelections[0].after ||
|
||||
indexSelections[0].attribution.origin.Locations[0].SourceID != "parent" || indexSelections[1].payload.substep != 1 ||
|
||||
!indexSelections[1].before || indexSelections[1].after {
|
||||
t.Fatalf("sequence-index inheritance selections = %#v", indexSelections)
|
||||
}
|
||||
}
|
||||
294
internal/engine/history_model.go
Normal file
294
internal/engine/history_model.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type historyPhase uint8
|
||||
|
||||
const (
|
||||
historyPhaseBaseline historyPhase = iota
|
||||
historyPhaseSources
|
||||
historyPhaseInheritance
|
||||
historyPhasePreCLI
|
||||
historyPhaseCLI
|
||||
historyPhasePostCLI
|
||||
historyPhaseResolution
|
||||
)
|
||||
|
||||
type historyRole uint8
|
||||
|
||||
const (
|
||||
historyRoleDefinition historyRole = iota
|
||||
historyRoleContribution
|
||||
historyRoleCompositeDefinition
|
||||
historyRoleOperation
|
||||
historyRoleControl
|
||||
historyRoleCheckpoint
|
||||
historyRoleInventory
|
||||
)
|
||||
|
||||
type historyPayloadRef struct {
|
||||
phase historyPhase
|
||||
id int
|
||||
substep int
|
||||
}
|
||||
|
||||
type historyAttributionRef int
|
||||
|
||||
const noHistoryAttribution historyAttributionRef = -1
|
||||
|
||||
type historyAttribution struct {
|
||||
origin Origin
|
||||
reasons []string
|
||||
dependencies []Path
|
||||
}
|
||||
|
||||
type historyObservation struct {
|
||||
path Path
|
||||
phase historyPhase
|
||||
payload historyPayloadRef
|
||||
reportedPath Path
|
||||
role historyRole
|
||||
beforePresent bool
|
||||
afterPresent bool
|
||||
sensitive bool
|
||||
attributionRef historyAttributionRef
|
||||
}
|
||||
|
||||
type historyModel struct {
|
||||
observations []historyObservation
|
||||
attributions []historyAttribution
|
||||
baseline *document.Node
|
||||
final *document.Node
|
||||
sourcePayloads []historySourcePayload
|
||||
inheritancePayloads []historyInheritancePayload
|
||||
cliPayloads []historySourcePayload
|
||||
resolutionPayload *historyResolutionPayload
|
||||
preCLICheckpoint *historyCheckpointPayload
|
||||
postCLICheckpoint *historyCheckpointPayload
|
||||
|
||||
shapesByPointer map[string][]Path
|
||||
shapeOrder []Path
|
||||
observationsByPointer map[string][]int
|
||||
affectedPathIndex pathPrefixIndex
|
||||
documentLookup *documentLookup
|
||||
|
||||
frozen bool
|
||||
}
|
||||
|
||||
// historyBuilder owns the model while loading. Its scratch document is one
|
||||
// mutable working projection used to resolve concrete sides of relative
|
||||
// producer effects; it is not retained by the published model.
|
||||
type historyBuilder struct {
|
||||
model *historyModel
|
||||
nextPayloadIDs map[historyPhase]int
|
||||
scratch *document.Node
|
||||
prefixReady bool
|
||||
}
|
||||
|
||||
func newHistoryBuilder() *historyBuilder {
|
||||
return &historyBuilder{
|
||||
model: &historyModel{
|
||||
shapesByPointer: make(map[string][]Path),
|
||||
observationsByPointer: make(map[string][]int),
|
||||
},
|
||||
nextPayloadIDs: make(map[historyPhase]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) nextPayload(phase historyPhase) historyPayloadRef {
|
||||
builder.requireOpen()
|
||||
id := builder.nextPayloadIDs[phase]
|
||||
builder.nextPayloadIDs[phase] = id + 1
|
||||
return historyPayloadRef{phase: phase, id: id}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addAttribution(origin Origin, reasons []string, dependencies []Path) historyAttributionRef {
|
||||
builder.requireOpen()
|
||||
ref := historyAttributionRef(len(builder.model.attributions))
|
||||
builder.model.attributions = append(builder.model.attributions, historyAttribution{
|
||||
origin: cloneOrigin(origin),
|
||||
reasons: append([]string(nil), reasons...),
|
||||
dependencies: clonePaths(dependencies),
|
||||
})
|
||||
return ref
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addObservation(observation historyObservation) int {
|
||||
builder.requireOpen()
|
||||
if !observation.path.valid() || !concreteHistoryPath(observation.path) || !observation.reportedPath.valid() {
|
||||
return -1
|
||||
}
|
||||
if observation.attributionRef < noHistoryAttribution || int(observation.attributionRef) >= len(builder.model.attributions) {
|
||||
return -1
|
||||
}
|
||||
observation.path = observation.path.clone()
|
||||
observation.reportedPath = observation.reportedPath.clone()
|
||||
observationID := len(builder.model.observations)
|
||||
builder.model.observations = append(builder.model.observations, observation)
|
||||
builder.addShape(observation.path)
|
||||
switch observation.role {
|
||||
case historyRoleDefinition, historyRoleContribution, historyRoleCompositeDefinition, historyRoleCheckpoint, historyRoleOperation, historyRoleControl:
|
||||
pointer := observation.path.String()
|
||||
builder.model.observationsByPointer[pointer] = append(builder.model.observationsByPointer[pointer], observationID)
|
||||
if observation.role == historyRoleContribution || observation.role == historyRoleOperation || observation.role == historyRoleControl {
|
||||
// Index the concrete affected path, not the common reported ancestor.
|
||||
// A leaf query must not enumerate every sibling fact of one operation.
|
||||
builder.model.affectedPathIndex.insert(pointer, observationID)
|
||||
}
|
||||
}
|
||||
return observationID
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeDocument(root *document.Node, phase historyPhase) {
|
||||
builder.observeDocumentAt(root, Path{}, phase)
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeDocumentAt(root *document.Node, prefix Path, phase historyPhase) {
|
||||
builder.requireOpen()
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
payload := builder.nextPayload(phase)
|
||||
walkDocument(root, prefix, func(path Path, node *document.Node) {
|
||||
builder.addInventoryObservation(path, path, payload, phase, node.Sensitive)
|
||||
})
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addInventoryObservation(path, reportedPath Path, payload historyPayloadRef, phase historyPhase, sensitive bool) int {
|
||||
return builder.addObservation(historyObservation{
|
||||
path: path, phase: phase, payload: payload,
|
||||
reportedPath: reportedPath, role: historyRoleInventory,
|
||||
sensitive: sensitive, attributionRef: noHistoryAttribution,
|
||||
})
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) setScratch(root *document.Node) {
|
||||
builder.requireOpen()
|
||||
builder.scratch = document.Clone(root)
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) scratchAt(path Path) (*document.Node, bool) {
|
||||
builder.requireOpen()
|
||||
return documentAtPath(builder.scratch, path)
|
||||
}
|
||||
|
||||
// advanceScratch transfers the next working projection to the builder. Callers
|
||||
// use it after applying existing value-replay helpers to the current scratch
|
||||
// document, avoiding a retained tree copy for each operation.
|
||||
func (builder *historyBuilder) advanceScratch(root *document.Node) {
|
||||
builder.requireOpen()
|
||||
builder.scratch = root
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) freeze() *historyModel {
|
||||
builder.requireOpen()
|
||||
builder.scratch = nil
|
||||
builder.model.documentLookup = buildDocumentLookup(builder.model.documentRoots()...)
|
||||
builder.model.frozen = true
|
||||
model := builder.model
|
||||
builder.model = nil
|
||||
builder.nextPayloadIDs = nil
|
||||
return model
|
||||
}
|
||||
|
||||
func (model *historyModel) documentRoots() []*document.Node {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
var roots []*document.Node
|
||||
appendRoot := func(root *document.Node) {
|
||||
if root != nil {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
appendRoot(model.baseline)
|
||||
appendRoot(model.final)
|
||||
for _, payload := range model.sourcePayloads {
|
||||
if payload.kind == historySourceApplicationPayload {
|
||||
appendRoot(payload.application.value)
|
||||
}
|
||||
}
|
||||
for _, payload := range model.inheritancePayloads {
|
||||
for _, operation := range payload.operations {
|
||||
appendRoot(operation.parent)
|
||||
appendRoot(operation.effective)
|
||||
}
|
||||
}
|
||||
for _, payload := range model.cliPayloads {
|
||||
if payload.kind == historySourceApplicationPayload {
|
||||
appendRoot(payload.application.value)
|
||||
}
|
||||
}
|
||||
if model.preCLICheckpoint != nil {
|
||||
appendRoot(model.preCLICheckpoint.root)
|
||||
}
|
||||
if model.postCLICheckpoint != nil {
|
||||
appendRoot(model.postCLICheckpoint.root)
|
||||
}
|
||||
if model.resolutionPayload != nil {
|
||||
for _, snapshot := range model.resolutionPayload.snapshots {
|
||||
appendRoot(snapshot.before)
|
||||
appendRoot(snapshot.after)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func (model *historyModel) documentAt(root *document.Node, path Path) (*document.Node, bool) {
|
||||
if model == nil || model.documentLookup == nil {
|
||||
return nil, false
|
||||
}
|
||||
return documentAtPathWithLookup(root, path, model.documentLookup)
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addShape(path Path) {
|
||||
pointer := path.String()
|
||||
for _, existing := range builder.model.shapesByPointer[pointer] {
|
||||
if sameConcreteHistoryPath(existing, path) {
|
||||
return
|
||||
}
|
||||
}
|
||||
owned := path.clone()
|
||||
builder.model.shapesByPointer[pointer] = append(builder.model.shapesByPointer[pointer], owned)
|
||||
builder.model.shapeOrder = append(builder.model.shapeOrder, owned.clone())
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) requireOpen() {
|
||||
if builder == nil || builder.model == nil || builder.model.frozen {
|
||||
panic("configkit: history builder is no longer active")
|
||||
}
|
||||
}
|
||||
|
||||
func concreteHistoryPath(path Path) bool {
|
||||
for _, segment := range path.segments {
|
||||
if segment.kind == pathSegmentParsed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameConcreteHistoryPath(left, right Path) bool {
|
||||
if len(left.segments) != len(right.segments) {
|
||||
return false
|
||||
}
|
||||
for index, leftSegment := range left.segments {
|
||||
rightSegment := right.segments[index]
|
||||
if leftSegment.kind != rightSegment.kind {
|
||||
return false
|
||||
}
|
||||
switch leftSegment.kind {
|
||||
case pathSegmentKey:
|
||||
if leftSegment.key != rightSegment.key {
|
||||
return false
|
||||
}
|
||||
case pathSegmentIndex:
|
||||
if leftSegment.index != rightSegment.index {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
123
internal/engine/history_resolution.go
Normal file
123
internal/engine/history_resolution.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
// resolutionReplayGroup is the explanation projection of one or more ordered
|
||||
// resolution records. The covering record identifies the before/after roots;
|
||||
// originalRecordIDs keep every coalesced record available to other projections.
|
||||
type resolutionReplayGroup struct {
|
||||
originalRecordIDs []int
|
||||
coveringRecordID int
|
||||
change Change
|
||||
}
|
||||
|
||||
type historyResolutionPayload struct {
|
||||
snapshots []resolutionSnapshot
|
||||
replayGroups []resolutionReplayGroup
|
||||
}
|
||||
|
||||
// observeResolutionEvidence retains audited records in producer order and
|
||||
// indexes the concrete shapes in every original before/after subtree. Snapshot
|
||||
// roots are already detached by runResolution and are shared with the replay
|
||||
// group that refers to their covering record.
|
||||
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot) {
|
||||
builder.requireOpen()
|
||||
if len(snapshots) == 0 {
|
||||
return
|
||||
}
|
||||
if builder.model.resolutionPayload != nil {
|
||||
panic("configkit: resolution history already registered")
|
||||
}
|
||||
if builder.scratch == nil {
|
||||
panic("configkit: resolution history requires the pre-resolution document")
|
||||
}
|
||||
|
||||
payload := &historyResolutionPayload{
|
||||
snapshots: make([]resolutionSnapshot, len(snapshots)),
|
||||
replayGroups: coalescedResolutionSnapshots(snapshots),
|
||||
}
|
||||
attributions := make([]historyAttributionRef, len(snapshots))
|
||||
lookup := buildDocumentLookup(builder.scratch)
|
||||
for recordID, snapshot := range snapshots {
|
||||
snapshot.change = cloneChange(snapshot.change)
|
||||
payload.snapshots[recordID] = snapshot
|
||||
|
||||
ref := builder.nextPayload(historyPhaseResolution)
|
||||
if ref.id != recordID {
|
||||
panic("configkit: resolution payload identifiers are out of order")
|
||||
}
|
||||
attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies)
|
||||
attributions[recordID] = attribution
|
||||
beforePath, beforeFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup)
|
||||
if beforeFound {
|
||||
observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false)
|
||||
}
|
||||
}
|
||||
for _, group := range payload.replayGroups {
|
||||
if group.coveringRecordID < 0 || group.coveringRecordID >= len(payload.snapshots) {
|
||||
continue
|
||||
}
|
||||
covering := payload.snapshots[group.coveringRecordID]
|
||||
replaceHistoryPath(&builder.scratch, group.change.Path, covering.after)
|
||||
}
|
||||
lookup = buildDocumentLookup(builder.scratch)
|
||||
for recordID, snapshot := range payload.snapshots {
|
||||
ref := historyPayloadRef{phase: historyPhaseResolution, id: recordID}
|
||||
afterPath, afterFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup)
|
||||
if afterFound {
|
||||
observeResolutionSubtree(builder, snapshot.after, afterPath, snapshot.change.Path, ref, attributions[recordID], false, true)
|
||||
}
|
||||
}
|
||||
builder.model.resolutionPayload = payload
|
||||
}
|
||||
|
||||
func observeResolutionSubtree(builder *historyBuilder, root *document.Node, path, reportedPath Path, ref historyPayloadRef, attribution historyAttributionRef, before, after bool) {
|
||||
walkDocument(root, path, func(path Path, node *document.Node) {
|
||||
builder.addObservation(historyObservation{
|
||||
path: path,
|
||||
phase: historyPhaseResolution,
|
||||
payload: ref,
|
||||
reportedPath: reportedPath,
|
||||
role: historyRoleOperation,
|
||||
beforePresent: before,
|
||||
afterPresent: after,
|
||||
sensitive: node.Sensitive,
|
||||
attributionRef: attribution,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func historyConcretePathAt(root *document.Node, path Path, lookup *documentLookup) (Path, bool) {
|
||||
if root == nil || !path.valid() {
|
||||
return Path{}, false
|
||||
}
|
||||
current := root
|
||||
var concrete Path
|
||||
for _, segment := range path.segments {
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return Path{}, false
|
||||
}
|
||||
position := lookup.find(current, segment.key)
|
||||
if position < 0 {
|
||||
return Path{}, false
|
||||
}
|
||||
current = current.Mapping[position].Value
|
||||
concrete = concrete.Key(segment.key)
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index >= len(current.Sequence) {
|
||||
return Path{}, false
|
||||
}
|
||||
current = current.Sequence[index]
|
||||
concrete = concrete.Index(index)
|
||||
default:
|
||||
return Path{}, false
|
||||
}
|
||||
if current == nil {
|
||||
return Path{}, false
|
||||
}
|
||||
}
|
||||
return concrete, true
|
||||
}
|
||||
195
internal/engine/history_resolution_test.go
Normal file
195
internal/engine/history_resolution_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestResolutionSensitivityKeepsLogicalEdgesWithoutSubtreeDuplication(t *testing.T) {
|
||||
t.Parallel()
|
||||
before := parseDocumentForDecode(t, "settings: {a: old, b: old, c: old}\ncredentials: {private: p, public: q, child: r}\nunrelated: visible\n")
|
||||
after := parseDocumentForDecode(t, "settings: {a: new, b: new, c: new}\n")
|
||||
beforeSettings, _ := documentAtPath(before, Keys("settings"))
|
||||
afterSettings, _ := documentAtPath(after, Keys("settings"))
|
||||
beforeChild, _ := documentAtPath(before, Keys("settings", "a"))
|
||||
afterChild, _ := documentAtPath(after, Keys("settings", "a"))
|
||||
first := parseOverrideTestPath(t, "/credentials/private")
|
||||
second := parseOverrideTestPath(t, "/credentials/public")
|
||||
child := parseOverrideTestPath(t, "/credentials/child")
|
||||
builder := newHistoryBuilder()
|
||||
builder.observeBaseline(before)
|
||||
builder.setScratch(before)
|
||||
builder.observeResolutionEvidence([]resolutionSnapshot{
|
||||
{change: Change{Path: Keys("settings"), Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization}, Dependencies: []Path{first, second}}, before: beforeSettings, after: afterSettings},
|
||||
{change: Change{Path: Keys("settings", "a"), Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization}, Dependencies: []Path{child}}, before: beforeChild, after: afterChild},
|
||||
})
|
||||
model := builder.freeze()
|
||||
dependencies, inherited := model.sensitivityDependencies()
|
||||
if len(dependencies) != 3 || len(inherited) != 0 {
|
||||
t.Fatalf("got %d ordinary and %d inherited edges; want the three original dependencies", len(dependencies), len(inherited))
|
||||
}
|
||||
for index, expected := range []struct{ target, dependency string }{
|
||||
{"/settings", first.String()}, {"/settings", second.String()}, {"/settings/a", child.String()},
|
||||
} {
|
||||
if dependencies[index].target.String() != expected.target || dependencies[index].dependency.String() != expected.dependency {
|
||||
t.Fatalf("edge %d lost its recorded target/dependency", index)
|
||||
}
|
||||
}
|
||||
classified := classifySecretPaths(nil, func(path Path) bool {
|
||||
return path.String() == child.String()
|
||||
}, Metadata{historyModel: model})
|
||||
if !classified["/settings/a"] || classified["/settings"] || classified["/settings/b"] || classified["/unrelated"] {
|
||||
t.Fatalf("original child dependency omitted by replay grouping or propagated to unrelated values: %#v", classified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryResolutionRetainsOriginalShapeAttributionBehindReplayGroup(t *testing.T) {
|
||||
before := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "0", Value: &document.Node{Kind: document.String, Text: "old"},
|
||||
}}}
|
||||
after := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
|
||||
Kind: document.String, Text: "new", Sensitive: true,
|
||||
}}}
|
||||
baseline := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "settings", Value: before,
|
||||
}}}
|
||||
parentDependency := Keys("parent-dependency")
|
||||
childDependency := Keys("child-dependency")
|
||||
snapshots := []resolutionSnapshot{
|
||||
{
|
||||
change: Change{
|
||||
Path: Keys("settings"), Origin: Origin{Layer: LayerResolution, Operation: ChangeDerivedDefault},
|
||||
Reasons: []string{"parent normalization"}, Dependencies: []Path{parentDependency},
|
||||
},
|
||||
before: before, after: after,
|
||||
},
|
||||
{
|
||||
change: Change{
|
||||
Path: parseOverrideTestPath(t, "/settings/0"), Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
||||
Reasons: []string{"child normalization"}, Dependencies: []Path{childDependency},
|
||||
},
|
||||
before: before.Mapping[0].Value,
|
||||
after: after.Sequence[0],
|
||||
},
|
||||
}
|
||||
|
||||
builder := newHistoryBuilder()
|
||||
builder.observeBaseline(baseline)
|
||||
builder.setScratch(baseline)
|
||||
builder.observeResolutionEvidence(snapshots)
|
||||
model := builder.freeze()
|
||||
|
||||
if got := model.resolutionPayload.replayGroups; len(got) != 1 ||
|
||||
got[0].coveringRecordID != 0 || !reflect.DeepEqual(got[0].originalRecordIDs, []int{0, 1}) ||
|
||||
!reflect.DeepEqual(got[0].change.Reasons, []string{"parent normalization", "child normalization"}) ||
|
||||
!reflect.DeepEqual(got[0].change.Dependencies, []Path{parentDependency, childDependency}) ||
|
||||
got[0].change.Origin.Operation != ChangeNormalization {
|
||||
t.Fatalf("replay group lost original references or merged annotations: %#v", got)
|
||||
}
|
||||
if len(model.resolutionPayload.snapshots) != 2 ||
|
||||
model.resolutionPayload.snapshots[1].change.Reasons[0] != "child normalization" ||
|
||||
model.resolutionPayload.snapshots[1].change.Dependencies[0].String() != childDependency.String() ||
|
||||
model.resolutionPayload.snapshots[0].before != before || model.resolutionPayload.snapshots[1].after != after.Sequence[0] {
|
||||
t.Fatalf("original resolution records or their roots were not retained: %#v", model.resolutionPayload.snapshots)
|
||||
}
|
||||
|
||||
keyPath := Keys("settings", "0")
|
||||
keySelections := model.selectHistory(keyPath)
|
||||
if len(keySelections) != 3 || !keySelections[0].after || keySelections[1].phase != historyPhaseResolution ||
|
||||
!keySelections[1].before || keySelections[1].after || !keySelections[2].before || keySelections[2].after {
|
||||
t.Fatalf("mapping-key history did not retain its removed side: %#v", keySelections)
|
||||
}
|
||||
if got := keySelections[1].previous; len(got) != 1 || got[0].Layer != LayerDefault {
|
||||
t.Fatalf("removed key previous origin = %#v, want baseline origin", got)
|
||||
}
|
||||
|
||||
indexPath := Keys("settings").Index(0)
|
||||
indexSelections := model.selectHistory(indexPath)
|
||||
if len(indexSelections) != 2 || indexSelections[0].phase != historyPhaseResolution ||
|
||||
!indexSelections[0].after || indexSelections[0].before || !indexSelections[1].after || indexSelections[1].before {
|
||||
t.Fatalf("sequence-index history did not retain introduced sides: %#v", indexSelections)
|
||||
}
|
||||
if len(indexSelections[0].previous) != 0 {
|
||||
t.Fatalf("introduced index inherited a different key's previous origin: %#v", indexSelections[0].previous)
|
||||
}
|
||||
if got := indexSelections[1].attribution; got.origin.Operation != ChangeNormalization ||
|
||||
!reflect.DeepEqual(got.reasons, []string{"child normalization"}) ||
|
||||
!reflect.DeepEqual(got.dependencies, []Path{childDependency}) {
|
||||
t.Fatalf("covered original attribution = %#v", got)
|
||||
}
|
||||
|
||||
foundSensitive := false
|
||||
for _, observation := range model.observations {
|
||||
if observation.phase == historyPhaseResolution && sameConcreteHistoryPath(observation.path, indexPath) && observation.sensitive {
|
||||
foundSensitive = true
|
||||
}
|
||||
}
|
||||
if !foundSensitive {
|
||||
t.Fatal("original resolution subtree sensitivity was omitted from observations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataFinalizationRegistersResolutionInventoryAndRoots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
beforeShape := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "0", Value: &document.Node{Kind: document.String, Text: "old"},
|
||||
}}}
|
||||
afterShape := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
|
||||
Kind: document.String, Text: "new", Sensitive: true,
|
||||
}}}
|
||||
before := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "shape", Value: beforeShape}}}
|
||||
final := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "shape", Value: afterShape}}}
|
||||
changePath := parseOverrideTestPath(t, "/shape/0")
|
||||
snapshot := resolutionSnapshot{
|
||||
change: Change{
|
||||
Path: changePath, Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
||||
Reasons: []string{"changed dynamic shape"}, Dependencies: []Path{Keys("source")},
|
||||
},
|
||||
before: beforeShape.Mapping[0].Value,
|
||||
after: afterShape.Sequence[0],
|
||||
}
|
||||
parentSnapshot := resolutionSnapshot{
|
||||
change: Change{
|
||||
Path: Keys("shape"), Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
||||
Reasons: []string{"replaced container"},
|
||||
},
|
||||
before: beforeShape,
|
||||
after: afterShape,
|
||||
}
|
||||
|
||||
var metadata Metadata
|
||||
snapshots := []resolutionSnapshot{parentSnapshot, snapshot}
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(before)
|
||||
builder.observeDocument(before, historyPhaseBaseline)
|
||||
builder.observeDocument(final, historyPhasePostCLI)
|
||||
builder.prefixReady = true
|
||||
builder.observeResolutionEvidence(snapshots)
|
||||
metadata.historyBuilder = builder
|
||||
freezeMetadataHistory(&metadata, final)
|
||||
if got := metadata.HistoricalPaths(); len(got) != 3 || got[0].String() != "" || got[1].String() != "/shape" || got[2].String() != "/shape/0" {
|
||||
t.Fatalf("historical paths = %#v", got)
|
||||
}
|
||||
keyPath := Keys("shape", "0")
|
||||
indexPath := Keys("shape").Index(0)
|
||||
if !metadata.hasHistoricalPath(keyPath) || !metadata.hasHistoricalPath(indexPath) {
|
||||
t.Fatalf("resolution shape inventory omitted key or index: %#v", metadata.HistoricalPaths())
|
||||
}
|
||||
if got := metadata.historyModel.selectHistory(keyPath); len(got) != 2 || !got[0].before || got[0].after || !got[1].before || got[1].after {
|
||||
t.Fatalf("removed mapping-key resolution selection = %#v", got)
|
||||
}
|
||||
if got := metadata.historyModel.selectHistory(indexPath); len(got) != 2 || got[0].before || !got[0].after || got[1].before || !got[1].after {
|
||||
t.Fatalf("introduced sequence-index resolution selection = %#v", got)
|
||||
}
|
||||
var retainedBefore, retainedAfter bool
|
||||
for _, root := range metadata.historyModel.documentRoots() {
|
||||
retainedBefore = retainedBefore || root == snapshot.before
|
||||
retainedAfter = retainedAfter || root == snapshot.after
|
||||
}
|
||||
if !retainedBefore || !retainedAfter {
|
||||
t.Fatalf("resolution roots not enumerated: before=%v after=%v", retainedBefore, retainedAfter)
|
||||
}
|
||||
}
|
||||
369
internal/engine/history_select.go
Normal file
369
internal/engine/history_select.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type historySelection struct {
|
||||
path Path
|
||||
phase historyPhase
|
||||
payload historyPayloadRef
|
||||
reportedPath Path
|
||||
role historyRole
|
||||
before bool
|
||||
after bool
|
||||
attribution historyAttribution
|
||||
hasAttribution bool
|
||||
previous []Origin
|
||||
}
|
||||
|
||||
type historySensitivityCandidate struct {
|
||||
path Path
|
||||
sensitive bool
|
||||
classify bool
|
||||
}
|
||||
|
||||
func (model *historyModel) selectHistory(path Path) []historySelection {
|
||||
if model == nil || !path.valid() || !model.hasObservedShape(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidateSet := make(map[int]struct{})
|
||||
for _, observationID := range model.observationsByPointer[path.String()] {
|
||||
candidateSet[observationID] = struct{}{}
|
||||
}
|
||||
// Producers register all affected before/after paths. Exact facts cover
|
||||
// ancestor replacements; descendants cover operations within an enclosing
|
||||
// query. Logical operations are still deduplicated by payload below.
|
||||
model.affectedPathIndex.visitDescendants(path.String(), func(observationID int) {
|
||||
candidateSet[observationID] = struct{}{}
|
||||
})
|
||||
candidates := make([]int, 0, len(candidateSet))
|
||||
for observationID := range candidateSet {
|
||||
candidates = append(candidates, observationID)
|
||||
}
|
||||
slices.Sort(candidates)
|
||||
|
||||
selections := make([]historySelection, 0, len(candidates))
|
||||
selectionByPayload := make(map[historyPayloadRef]int)
|
||||
for _, observationID := range candidates {
|
||||
observation := model.observations[observationID]
|
||||
if observation.role == historyRoleInventory {
|
||||
continue
|
||||
}
|
||||
reportedPath := observation.reportedPath
|
||||
attributionRef := observation.attributionRef
|
||||
if observation.role == historyRoleContribution {
|
||||
if !historyObservationMatchesQuery(path, observation.path) {
|
||||
continue
|
||||
}
|
||||
application, found := model.applicationPayload(observation.payload)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if pathContains(observation.path, path) && len(observation.path.segments) < len(path.segments) {
|
||||
local, ok := historyApplicationLocalPath(application, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, present := documentAtPathWithLookup(application.value, local, model.documentLookup); !present {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant {
|
||||
reportedPath = operationPath
|
||||
}
|
||||
} else if !historyObservationMatchesQuery(path, observation.path) {
|
||||
continue
|
||||
}
|
||||
selectionIndex, found := selectionByPayload[observation.payload]
|
||||
if !found {
|
||||
selectionIndex = len(selections)
|
||||
selectionByPayload[observation.payload] = selectionIndex
|
||||
selection := historySelection{
|
||||
path: path.clone(),
|
||||
phase: observation.phase,
|
||||
payload: observation.payload,
|
||||
reportedPath: reportedPath.clone(),
|
||||
role: observation.role,
|
||||
}
|
||||
if attribution, ok := model.resolveAttribution(attributionRef); ok {
|
||||
selection.attribution = attribution
|
||||
selection.hasAttribution = true
|
||||
}
|
||||
selections = append(selections, selection)
|
||||
}
|
||||
selection := &selections[selectionIndex]
|
||||
selection.before = selection.before || observation.beforePresent
|
||||
selection.after = selection.after || observation.afterPresent
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var previous *Origin
|
||||
for index := range selections {
|
||||
selection := &selections[index]
|
||||
if selection.hasAttribution && metadataHistoryRole(selection.role) {
|
||||
if previous != nil {
|
||||
selection.previous = []Origin{cloneOrigin(*previous)}
|
||||
}
|
||||
origin := cloneOrigin(selection.attribution.origin)
|
||||
previous = &origin
|
||||
}
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
func historyObservationMatchesQuery(query, observed Path) bool {
|
||||
if matchesPathShape(query, observed) {
|
||||
return true
|
||||
}
|
||||
return pathContains(query, observed) && pathShapePrefixMatches(query, observed)
|
||||
}
|
||||
|
||||
func (model *historyModel) hasObservedShape(path Path) bool {
|
||||
if model == nil || !path.valid() {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range model.shapesByPointer[path.String()] {
|
||||
if matchesPathShape(path, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (model *historyModel) concreteShapes(pointer string) []Path {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
shapes := model.shapesByPointer[pointer]
|
||||
cloned := make([]Path, len(shapes))
|
||||
for index, path := range shapes {
|
||||
cloned[index] = path.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (model *historyModel) observedPaths() []Path {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
paths := make([]Path, len(model.shapeOrder))
|
||||
for index, path := range model.shapeOrder {
|
||||
paths[index] = path.clone()
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (model *historyModel) historicalPaths() []Path {
|
||||
if model == nil {
|
||||
return []Path{}
|
||||
}
|
||||
pointers := make([]string, 0, len(model.shapesByPointer))
|
||||
for pointer := range model.shapesByPointer {
|
||||
pointers = append(pointers, pointer)
|
||||
}
|
||||
sort.Strings(pointers)
|
||||
paths := make([]Path, 0, len(pointers))
|
||||
for _, pointer := range pointers {
|
||||
path, err := ParsePath(pointer)
|
||||
if err == nil {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (model *historyModel) latestHistoricalLocation(path Path) (Location, bool) {
|
||||
selections := model.selectHistory(path)
|
||||
for selectionIndex := len(selections) - 1; selectionIndex >= 0; selectionIndex-- {
|
||||
selection := selections[selectionIndex]
|
||||
if selection.hasAttribution {
|
||||
if location, found := latestOriginDocumentLocation(selection.attribution.origin); found {
|
||||
return Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}, true
|
||||
}
|
||||
}
|
||||
for previousIndex := len(selection.previous) - 1; previousIndex >= 0; previousIndex-- {
|
||||
if location, found := latestOriginDocumentLocation(selection.previous[previousIndex]); found {
|
||||
return Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Location{}, false
|
||||
}
|
||||
|
||||
func (model *historyModel) sensitivityCandidates(inherited []secretDependency) []historySensitivityCandidate {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
candidates := make([]historySensitivityCandidate, 0, len(model.observations))
|
||||
indices := make(map[string][]int)
|
||||
seenAttributions := make(map[historyAttributionRef]struct{})
|
||||
add := func(path Path, sensitive, classify bool) {
|
||||
if !path.valid() {
|
||||
return
|
||||
}
|
||||
pointer := path.String()
|
||||
for _, index := range indices[pointer] {
|
||||
if slices.Equal(candidates[index].path.segments, path.segments) {
|
||||
candidates[index].sensitive = candidates[index].sensitive || sensitive
|
||||
candidates[index].classify = candidates[index].classify || classify
|
||||
return
|
||||
}
|
||||
}
|
||||
index := len(candidates)
|
||||
candidates = append(candidates, historySensitivityCandidate{path: path.clone(), sensitive: sensitive, classify: classify})
|
||||
indices[pointer] = append(indices[pointer], index)
|
||||
}
|
||||
for _, observation := range model.observations {
|
||||
concreteCandidate := observation.role == historyRoleControl
|
||||
if observation.role == historyRoleContribution {
|
||||
if application, found := model.applicationPayload(observation.payload); found && len(application.sequences) != 0 {
|
||||
concreteCandidate = true
|
||||
}
|
||||
}
|
||||
add(observation.path, observation.sensitive, concreteCandidate)
|
||||
if parsed, err := ParsePath(observation.path.String()); err == nil {
|
||||
add(parsed, observation.sensitive, true)
|
||||
}
|
||||
if _, seen := seenAttributions[observation.attributionRef]; seen {
|
||||
continue
|
||||
}
|
||||
seenAttributions[observation.attributionRef] = struct{}{}
|
||||
if attribution, found := model.resolveAttribution(observation.attributionRef); found {
|
||||
for _, dependency := range attribution.dependencies {
|
||||
if dependency.valid() {
|
||||
add(dependency, false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, link := range inherited {
|
||||
add(link.target, false, true)
|
||||
add(link.dependency, false, true)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// sensitivityDependencies returns the original attribution edges and the
|
||||
// bidirectional links represented by inheritance payloads. It intentionally
|
||||
// does not derive edges from the public metadata projection, which may omit
|
||||
// evidence needed to keep historical values redacted.
|
||||
func (model *historyModel) sensitivityDependencies() (dependencies, inherited []secretDependency) {
|
||||
if model == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Every affected subtree fact borrows its operation's attribution. Expand
|
||||
// each logical target/dependency list once, not once for every descendant.
|
||||
type dependencyAttribution struct {
|
||||
ref historyAttributionRef
|
||||
target string
|
||||
}
|
||||
seen := make(map[dependencyAttribution]struct{})
|
||||
for _, observation := range model.observations {
|
||||
if observation.phase == historyPhaseInheritance || observation.attributionRef == noHistoryAttribution {
|
||||
continue
|
||||
}
|
||||
key := dependencyAttribution{ref: observation.attributionRef, target: observation.reportedPath.String()}
|
||||
if _, found := seen[key]; found {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
attribution, found := model.resolveAttribution(observation.attributionRef)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
for _, dependency := range attribution.dependencies {
|
||||
if observation.reportedPath.valid() && dependency.valid() {
|
||||
dependencies = append(dependencies, secretDependency{
|
||||
target: observation.reportedPath.clone(), dependency: dependency.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, payload := range model.inheritancePayloads {
|
||||
for _, operation := range payload.operations {
|
||||
if operation.control != nil {
|
||||
control := operation.control.path
|
||||
for _, contributor := range operation.controlOrigin.contributors {
|
||||
if control.valid() && contributor.source.valid() {
|
||||
inherited = append(inherited, secretDependency{
|
||||
target: control.clone(), dependency: contributor.source.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if operation.transfer == nil || operation.parent == nil {
|
||||
continue
|
||||
}
|
||||
transfer := operation.transfer
|
||||
walkDocument(operation.parent, transfer.to, func(destination Path, _ *document.Node) {
|
||||
relative, ok := relativeHistoryPath(transfer.to, destination)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
source := appendInheritancePath(transfer.from, relative)
|
||||
if _, found := operation.parentOrigins[source.String()]; !found {
|
||||
return
|
||||
}
|
||||
inherited = append(inherited, secretDependency{
|
||||
target: destination.clone(), dependency: source,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
return dependencies, inherited
|
||||
}
|
||||
|
||||
func (model *historyModel) resolveAttribution(ref historyAttributionRef) (historyAttribution, bool) {
|
||||
if model == nil || ref < 0 || int(ref) >= len(model.attributions) {
|
||||
return historyAttribution{}, false
|
||||
}
|
||||
attribution := model.attributions[ref]
|
||||
return historyAttribution{
|
||||
origin: cloneOrigin(attribution.origin),
|
||||
reasons: append([]string(nil), attribution.reasons...),
|
||||
dependencies: clonePaths(attribution.dependencies),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (model *historyModel) applicationPayload(ref historyPayloadRef) (historyApplication, bool) {
|
||||
if model == nil || ref.id < 0 {
|
||||
return historyApplication{}, false
|
||||
}
|
||||
var payloads []historySourcePayload
|
||||
switch ref.phase {
|
||||
case historyPhaseSources:
|
||||
payloads = model.sourcePayloads
|
||||
case historyPhaseCLI:
|
||||
payloads = model.cliPayloads
|
||||
default:
|
||||
return historyApplication{}, false
|
||||
}
|
||||
if ref.id >= len(payloads) || payloads[ref.id].kind != historySourceApplicationPayload {
|
||||
return historyApplication{}, false
|
||||
}
|
||||
return payloads[ref.id].application, true
|
||||
}
|
||||
|
||||
func pathShapePrefixMatches(left, right Path) bool {
|
||||
length := min(len(left.segments), len(right.segments))
|
||||
leftPrefix := Path{segments: clonePathSegments(left.segments[:length], 0)}
|
||||
rightPrefix := Path{segments: clonePathSegments(right.segments[:length], 0)}
|
||||
return matchesPathShape(leftPrefix, rightPrefix)
|
||||
}
|
||||
|
||||
func metadataHistoryRole(role historyRole) bool {
|
||||
switch role {
|
||||
case historyRoleDefinition, historyRoleCompositeDefinition, historyRoleOperation, historyRoleControl:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
283
internal/engine/history_select_test.go
Normal file
283
internal/engine/history_select_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestSensitivityCandidatesMergeRepeatedParsedPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newHistoryBuilder()
|
||||
key := Keys("shape", "0")
|
||||
index := Keys("shape").Index(0)
|
||||
parsed := parseOverrideTestPath(t, "/shape/0")
|
||||
dependency := parseOverrideTestPath(t, "/dependency")
|
||||
attribution := builder.addAttribution(Origin{Layer: LayerResolution}, nil, []Path{dependency})
|
||||
payload := builder.nextPayload(historyPhaseResolution)
|
||||
for repeat := 0; repeat < 3; repeat++ {
|
||||
for _, path := range []Path{key, index} {
|
||||
builder.addObservation(historyObservation{
|
||||
path: path, phase: historyPhaseResolution, payload: payload,
|
||||
reportedPath: Keys("shape"), role: historyRoleOperation,
|
||||
afterPresent: true, sensitive: repeat == 2,
|
||||
attributionRef: attribution,
|
||||
})
|
||||
}
|
||||
}
|
||||
model := builder.freeze()
|
||||
_, inherited := model.sensitivityDependencies()
|
||||
candidates := model.sensitivityCandidates(inherited)
|
||||
for _, path := range []Path{key, index, parsed, dependency} {
|
||||
count := 0
|
||||
for _, candidate := range candidates {
|
||||
if slices.Equal(candidate.path.segments, path.segments) {
|
||||
count++
|
||||
if path.String() == "/shape/0" && !candidate.sensitive {
|
||||
t.Fatal("merged candidate lost a later sensitivity fact")
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("semantic candidate %#v occurs %d times; want one", path, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySelectionSeparatesNumericShapesAndGroupsParsedSides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builder := newHistoryBuilder()
|
||||
mapping := parseDocumentForDecode(t, "shape: {'0': old}\n")
|
||||
sequence := parseDocumentForDecode(t, "shape: [new]\n")
|
||||
shapeNode, _ := documentAtPath(mapping, Keys("shape"))
|
||||
shapeNode.Sensitive = true
|
||||
builder.observeDocument(mapping, historyPhaseSources)
|
||||
builder.observeDocument(mapping, historyPhaseBaseline)
|
||||
builder.observeDocument(sequence, historyPhaseResolution)
|
||||
|
||||
baseOrigin := Origin{Layer: LayerMain}
|
||||
baseAttribution := builder.addAttribution(baseOrigin, nil, nil)
|
||||
keyPath := Keys("shape", "0")
|
||||
indexPath := Keys("shape").Index(0)
|
||||
definition := builder.nextPayload(historyPhaseSources)
|
||||
builder.addObservation(historyObservation{
|
||||
path: keyPath, phase: historyPhaseSources, payload: definition,
|
||||
reportedPath: keyPath, role: historyRoleDefinition,
|
||||
afterPresent: true, attributionRef: baseAttribution,
|
||||
})
|
||||
|
||||
replacementAttribution := builder.addAttribution(
|
||||
Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
||||
[]string{"replace dynamic shape"}, []Path{Keys("shape")},
|
||||
)
|
||||
replacement := builder.nextPayload(historyPhaseResolution)
|
||||
for _, observation := range []historyObservation{
|
||||
{
|
||||
path: keyPath, phase: historyPhaseResolution, payload: replacement,
|
||||
reportedPath: Keys("shape"), role: historyRoleOperation,
|
||||
beforePresent: true, attributionRef: replacementAttribution,
|
||||
},
|
||||
{
|
||||
path: indexPath, phase: historyPhaseResolution, payload: replacement,
|
||||
reportedPath: Keys("shape"), role: historyRoleOperation,
|
||||
afterPresent: true, attributionRef: replacementAttribution,
|
||||
},
|
||||
} {
|
||||
if got := builder.addObservation(observation); got < 0 {
|
||||
t.Fatal("failed to add concrete history observation")
|
||||
}
|
||||
}
|
||||
|
||||
model := builder.freeze()
|
||||
shapes := model.concreteShapes("/shape/0")
|
||||
if len(shapes) != 2 || shapes[0].segments[1].kind != pathSegmentKey || shapes[1].segments[1].kind != pathSegmentIndex {
|
||||
t.Fatalf("concrete shapes = %#v", shapes)
|
||||
}
|
||||
if !reflect.DeepEqual(model.observedPaths()[:2], []Path{Path{}, Keys("shape")}) {
|
||||
t.Fatalf("first observed paths = %#v", model.observedPaths()[:2])
|
||||
}
|
||||
historicalPaths := model.historicalPaths()
|
||||
var historicalPointers []string
|
||||
for _, path := range historicalPaths {
|
||||
historicalPointers = append(historicalPointers, path.String())
|
||||
}
|
||||
if !reflect.DeepEqual(historicalPointers, []string{"", "/shape", "/shape/0"}) {
|
||||
t.Fatalf("historical paths = %#v", historicalPointers)
|
||||
}
|
||||
if len(model.concreteShapes("/shape/0")) != 2 {
|
||||
t.Fatalf("repeated inventory added a duplicate concrete shape: %#v", model.concreteShapes("/shape/0"))
|
||||
}
|
||||
foundSensitive := false
|
||||
for _, observation := range model.observations {
|
||||
if observation.role == historyRoleInventory && observation.path.String() == "/shape" && observation.sensitive {
|
||||
foundSensitive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSensitive {
|
||||
t.Fatal("sensitive document fact was not retained with inventory evidence")
|
||||
}
|
||||
|
||||
keySelections := model.selectHistory(keyPath)
|
||||
if len(keySelections) != 2 || keySelections[0].role != historyRoleDefinition || keySelections[1].before != true || keySelections[1].after {
|
||||
t.Fatalf("mapping-key selections = %#v", keySelections)
|
||||
}
|
||||
if len(keySelections[1].previous) != 1 || keySelections[1].previous[0].Layer != LayerMain {
|
||||
t.Fatalf("mapping-key previous origins = %#v", keySelections[1].previous)
|
||||
}
|
||||
|
||||
indexSelections := model.selectHistory(indexPath)
|
||||
if len(indexSelections) != 1 || indexSelections[0].before || !indexSelections[0].after || len(indexSelections[0].previous) != 0 {
|
||||
t.Fatalf("sequence-index selections = %#v", indexSelections)
|
||||
}
|
||||
|
||||
parsed, err := ParsePath("/shape/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedSelections := model.selectHistory(parsed)
|
||||
if len(parsedSelections) != 2 || !parsedSelections[1].before || !parsedSelections[1].after {
|
||||
t.Fatalf("parsed-path selections = %#v", parsedSelections)
|
||||
}
|
||||
if !reflect.DeepEqual(parsedSelections[1].attribution.reasons, []string{"replace dynamic shape"}) ||
|
||||
len(parsedSelections[1].attribution.dependencies) != 1 || parsedSelections[1].attribution.dependencies[0].String() != "/shape" {
|
||||
t.Fatalf("resolved attribution = %#v", parsedSelections[1].attribution)
|
||||
}
|
||||
parsedSelections[1].attribution.reasons[0] = "mutated"
|
||||
parsedSelections[1].attribution.dependencies[0] = Path{}
|
||||
again := model.selectHistory(parsed)
|
||||
if again[1].attribution.reasons[0] != "replace dynamic shape" || again[1].attribution.dependencies[0].String() != "/shape" {
|
||||
t.Fatalf("selection mutation changed model attribution: %#v", again[1].attribution)
|
||||
}
|
||||
if selections := model.selectHistory(Keys("shape").Index(9)); len(selections) != 0 {
|
||||
t.Fatalf("never-observed index selections = %#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySelectionMatchesDeepSidesOfAncestorShapeChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builder := newHistoryBuilder()
|
||||
builder.observeDocument(parseDocumentForDecode(t, "shape: {'0': {leaf: old}}\n"), historyPhaseSources)
|
||||
builder.observeDocument(parseDocumentForDecode(t, "shape: [{leaf: new}]\n"), historyPhaseResolution)
|
||||
base := builder.addAttribution(Origin{Layer: LayerMain}, nil, nil)
|
||||
oldPath := Keys("shape", "0", "leaf")
|
||||
newPath := Keys("shape").Index(0).Key("leaf")
|
||||
definition := builder.nextPayload(historyPhaseSources)
|
||||
builder.addObservation(historyObservation{
|
||||
path: oldPath, phase: historyPhaseSources, payload: definition,
|
||||
reportedPath: oldPath, role: historyRoleDefinition,
|
||||
afterPresent: true, attributionRef: base,
|
||||
})
|
||||
operation := builder.nextPayload(historyPhaseResolution)
|
||||
operationAttribution := builder.addAttribution(Origin{Layer: LayerResolution, Operation: ChangeNormalization}, nil, nil)
|
||||
for _, observation := range []historyObservation{
|
||||
{path: oldPath, phase: historyPhaseResolution, payload: operation, reportedPath: Keys("shape"), role: historyRoleOperation, beforePresent: true, attributionRef: operationAttribution},
|
||||
{path: newPath, phase: historyPhaseResolution, payload: operation, reportedPath: Keys("shape"), role: historyRoleOperation, afterPresent: true, attributionRef: operationAttribution},
|
||||
} {
|
||||
builder.addObservation(observation)
|
||||
}
|
||||
model := builder.freeze()
|
||||
|
||||
newSelections := model.selectHistory(newPath)
|
||||
if len(newSelections) != 1 || newSelections[0].role != historyRoleOperation || newSelections[0].before || !newSelections[0].after || len(newSelections[0].previous) != 0 {
|
||||
t.Fatalf("new descendant selections = %#v", newSelections)
|
||||
}
|
||||
oldSelections := model.selectHistory(oldPath)
|
||||
if len(oldSelections) != 2 || !oldSelections[1].before || oldSelections[1].after || len(oldSelections[1].previous) != 1 || oldSelections[1].previous[0].Layer != LayerMain {
|
||||
t.Fatalf("removed descendant selections = %#v", oldSelections)
|
||||
}
|
||||
if selections := model.selectHistory(Keys("shape").Index(0).Key("missing")); len(selections) != 0 {
|
||||
t.Fatalf("never-observed descendant selections = %#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryModelFinalizesLocationAndSensitivityWithoutRetainingInventoryTrees(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := parseDocumentForDecode(t, "shape: {'0': {value: old}}\n")
|
||||
value, _ := documentAtPath(root, Keys("shape", "0", "value"))
|
||||
value.Sensitive = true
|
||||
keyPath := Keys("shape", "0", "value")
|
||||
dependency := Keys("credentials", "source")
|
||||
|
||||
builder := newHistoryBuilder()
|
||||
builder.observeDocument(root, historyPhaseSources)
|
||||
definitionAttribution := builder.addAttribution(Origin{
|
||||
Layer: LayerMain, Locations: []Location{{SourceID: "base.yml", Line: 4, Column: 2}},
|
||||
}, nil, nil)
|
||||
definition := builder.nextPayload(historyPhaseSources)
|
||||
builder.addObservation(historyObservation{
|
||||
path: keyPath, phase: historyPhaseSources, payload: definition,
|
||||
reportedPath: keyPath, role: historyRoleDefinition,
|
||||
afterPresent: true, attributionRef: definitionAttribution,
|
||||
})
|
||||
resolutionAttribution := builder.addAttribution(
|
||||
Origin{Layer: LayerResolution, Operation: ChangeNormalization},
|
||||
[]string{"normalized credential"}, []Path{dependency},
|
||||
)
|
||||
resolution := builder.nextPayload(historyPhaseResolution)
|
||||
builder.addObservation(historyObservation{
|
||||
path: keyPath, phase: historyPhaseResolution, payload: resolution,
|
||||
reportedPath: Keys("shape"), role: historyRoleOperation,
|
||||
beforePresent: true, afterPresent: true, sensitive: true,
|
||||
attributionRef: resolutionAttribution,
|
||||
})
|
||||
model := builder.freeze()
|
||||
|
||||
if got := model.documentRoots(); len(got) != 0 {
|
||||
t.Fatalf("inventory-only document was retained: %#v", got)
|
||||
}
|
||||
if got, ok := model.documentAt(root, Keys("shape", "0")); ok || got != nil {
|
||||
t.Fatalf("lookup retained an inventory-only document = %#v, %v", got, ok)
|
||||
}
|
||||
if got, ok := model.latestHistoricalLocation(keyPath); !ok || got.SourceID != "base.yml" || got.Line != 4 || got.Column != 2 {
|
||||
t.Fatalf("latest historical location = %#v, %v", got, ok)
|
||||
}
|
||||
|
||||
dependencies, inherited := model.sensitivityDependencies()
|
||||
candidates := model.sensitivityCandidates(inherited)
|
||||
var sawCandidate, sawDependency bool
|
||||
for _, candidate := range candidates {
|
||||
if sameConcreteHistoryPath(candidate.path, keyPath) && candidate.sensitive {
|
||||
sawCandidate = true
|
||||
}
|
||||
if slices.Equal(candidate.path.segments, dependency.segments) && candidate.classify {
|
||||
sawDependency = true
|
||||
}
|
||||
}
|
||||
if !sawCandidate || !sawDependency || len(dependencies) != 1 ||
|
||||
dependencies[0].target.String() != "/shape" || dependencies[0].dependency.String() != dependency.String() {
|
||||
t.Fatal("sensitivity evidence omitted the concrete historical path or its recorded dependency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryBuilderScratchUsesOneDetachedWorkingDocument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := parseDocumentForDecode(t, "shape: {value: original}\n")
|
||||
builder := newHistoryBuilder()
|
||||
builder.setScratch(input)
|
||||
inputValue, _ := documentAtPath(input, Keys("shape", "value"))
|
||||
inputValue.Text = "producer mutation"
|
||||
scratchValue, ok := builder.scratchAt(Keys("shape", "value"))
|
||||
if !ok || scratchValue.Text != "original" {
|
||||
t.Fatalf("scratch value after producer mutation = %#v, %v", scratchValue, ok)
|
||||
}
|
||||
|
||||
next := document.Clone(builder.scratch)
|
||||
nextValue, _ := documentAtPath(next, Keys("shape", "value"))
|
||||
nextValue.Text = "replayed"
|
||||
builder.advanceScratch(next)
|
||||
scratchValue, ok = builder.scratchAt(Keys("shape", "value"))
|
||||
if !ok || scratchValue.Text != "replayed" {
|
||||
t.Fatalf("advanced scratch value = %#v, %v", scratchValue, ok)
|
||||
}
|
||||
model := builder.freeze()
|
||||
if !model.frozen || builder.scratch != nil {
|
||||
t.Fatal("freeze retained mutable scratch state")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user