119 Commits

Author SHA1 Message Date
21c9bc0ad0 Retire completed roadmaps before the next release 2026-10-27 19:15:40 +00:00
68400ffdfb Fix source lookup tests with symlinked temporary directories 2026-10-27 18:42:19 +00:00
5d5d51ab11 Record the public API cleanup review 2026-10-27 16:25:33 +00:00
69bd9ead53 Complete the public API cleanup roadmap 2026-10-27 15:44:00 +00:00
2ff588eb64 Update guidance for the cleaned public API 2026-10-27 15:40:22 +00:00
c96197c417 Add detached source lookup to results and errors 2026-10-27 15:26:19 +00:00
24ab623cb2 Rename the retained history limit diagnostic 2026-10-27 15:18:57 +00:00
1ce147c9f6 Use main for the main provenance layer 2026-10-27 15:15:39 +00:00
9833ca3aa3 Replace generic resolution recording with intent methods 2026-10-27 15:12:38 +00:00
eafbd32489 Clarify search and secret bootstrap names 2026-10-27 15:09:04 +00:00
9ac0053590 Clarify import default field names 2026-10-27 15:07:15 +00:00
791cf16136 Simplify empty import selection evidence 2026-10-27 15:05:22 +00:00
31ae30372e Clarify source version evidence names 2026-10-27 15:01:54 +00:00
7ee9e92ef5 Clarify resolution metadata and sensitivity names 2026-10-27 15:00:04 +00:00
556b10b2cd Plan the public API cleanup 2026-10-27 14:55:22 +00:00
07e4ae5d92 Expand accepted API decisions into an implementation roadmap 2026-10-27 14:43:08 +00:00
7548fa101a Accept descriptive import default field names 2026-10-27 14:35:42 +00:00
197567177e Include failed-load source lookup in A1 2026-10-27 14:31:25 +00:00
7330107389 Include obsolete recording diagnostic removal in D6 2026-10-27 14:29:35 +00:00
ad6d3748d9 Accept history terminology for the retained evidence limit 2026-10-27 14:27:56 +00:00
1ee508ba02 Accept main terminology for provenance 2026-10-27 14:26:54 +00:00
e9155fced6 Accept the discovery path constructor rename 2026-10-27 14:25:18 +00:00
14c83a6515 Record additional public API review opportunities 2026-10-27 14:21:01 +00:00
3ebb848ead Accept direct source lookup on results 2026-10-27 14:10:51 +00:00
4289f9ff7f Record accepted public API cleanup decisions 2026-10-27 14:06:45 +00:00
a050986916 Catalog the public API for version one 2026-10-27 13:34:26 +00:00
fb71421956 Prepare sensitivity dependencies once per recorded operation 2026-10-27 03:18:55 +00:00
b281adb751 Index explanation selections once per query 2026-10-27 03:15:01 +00:00
1448f9b19d Deduplicate parsed sensitivity paths correctly 2026-10-27 03:12:21 +00:00
731149fc6d Localize history selection to affected paths 2026-10-27 03:10:07 +00:00
9ff873f54c Index stable history trees during normalization 2026-10-27 03:08:34 +00:00
3f389662e9 Keep explanation origins scoped to the query 2026-10-27 03:05:15 +00:00
906ca34b6d Avoid duplicate intermediate source history 2026-10-27 03:04:31 +00:00
17216a40f3 Preserve final dynamic secret tags in inspection 2026-10-27 03:02:17 +00:00
896c1cde08 Record additional history scaling findings 2026-10-27 03:00:14 +00:00
91769e9f9b Record interface remediation review findings 2026-10-27 02:52:47 +00:00
98c8b7c842 Complete interface contract remediation 2026-10-26 18:23:58 +00:00
1954052631 Preserve composition conflict claimant order 2026-10-26 18:17:04 +00:00
3ffaef7ede Remove superseded history attribution paths 2026-10-26 18:11:04 +00:00
6a1447c327 Reconcile retained history accounting 2026-10-26 17:46:31 +00:00
12fd0a7191 Publish inspection through one frozen history model 2026-10-26 17:36:36 +00:00
8155465322 Move sensitivity classification to shared history 2026-10-26 17:25:11 +00:00
408dd13c24 Integrate history prefixes into load handoffs 2026-10-26 17:15:30 +00:00
738c8a9cc7 Replay explanations through shared history selections 2026-10-26 17:01:32 +00:00
4b4e15fc58 Cut metadata history over to shared model 2026-10-26 16:34:13 +00:00
76521f7cd8 Complete historical inventory and query services 2026-10-26 16:09:32 +00:00
cacc558288 Normalize original resolution history evidence 2026-10-26 15:49:15 +00:00
231ae66857 Normalize typed CLI history evidence 2026-10-26 15:36:42 +00:00
da74d4190d Normalize inheritance history evidence 2026-10-26 15:25:02 +00:00
11f7154874 Normalize projected collection history evidence 2026-10-26 15:09:12 +00:00
3828b117e8 Normalize baseline and source history evidence 2026-10-26 15:01:54 +00:00
2419e63717 Add shape-aware history selection core 2026-10-26 14:47:18 +00:00
a893c7716c Preserve explicitness across resolution shape changes 2026-10-26 14:36:15 +00:00
28ef9b6200 Use entry metadata for resolution presence 2026-10-26 14:30:25 +00:00
0dd16aa8a9 Plan the interface remediation sprint 2026-10-26 14:24:07 +00:00
f6d54a094a Clarify shared history migration and sprint acceptance 2026-10-26 14:00:18 +00:00
f9ec66e867 Adopt shared history ownership in the interface roadmap 2026-10-26 12:36:17 +00:00
be5cdeb502 Plan interface contract remediation 2026-10-26 02:40:21 +00:00
1f64673d0d Record API contract audit findings 2026-10-26 01:36:20 +00:00
01b08e76a3 Retire the completed consolidated audit 2026-10-26 01:25:35 +00:00
453da684d7 Write release notes for v0.10.2 2026-10-26 01:19:38 +00:00
fb6c254d38 Mark import simplification roadmap complete 2026-10-26 00:21:57 +00:00
a71413ce67 Document import and facade boundaries 2026-10-26 00:21:40 +00:00
d66338a1c5 Construct placement directly across the facade 2026-10-26 00:18:12 +00:00
643f43ce6a Snapshot import defaults at load preflight 2026-10-26 00:13:34 +00:00
c25cf39d19 Consolidate engine import defaults 2026-10-26 00:10:07 +00:00
7d18c93339 Extract patterned glob entry preparation 2026-10-26 00:08:23 +00:00
b1d89810ca Plan discovery and facade boundary refactoring 2026-10-26 00:03:02 +00:00
bb5540b62e Plan import discovery and facade simplification 2026-10-25 23:55:45 +00:00
80cf23e710 Mark the implementation roadmap complete 2026-10-25 23:26:29 +00:00
ac48557535 Document and verify the remediation sprint 2026-10-25 23:26:10 +00:00
b4af900c10 Index secret failure locations lazily 2026-10-25 23:21:29 +00:00
f132cd6468 Index inherited compound identity restoration 2026-10-25 23:17:59 +00:00
75e209eb3b Index tier history sources lazily 2026-10-25 23:13:00 +00:00
1dd94a3cc6 Share sequence history construction 2026-10-25 23:09:41 +00:00
d172a5e46a Apply sparse collection policy across overlays 2026-10-25 23:05:23 +00:00
3a1b6ff355 Borrow lower collection policies during strict composition 2026-10-25 22:59:03 +00:00
68383f34de Index collection policy retirement 2026-10-25 22:49:02 +00:00
549fca239d Separate collection overlay responsibilities 2026-10-25 22:42:21 +00:00
883244936c Share layered tier application 2026-10-25 22:37:46 +00:00
a8ae2e9d5e Clarify load context and inheritance handoffs 2026-10-25 22:34:52 +00:00
f18582106b Isolate standalone input completion 2026-10-25 22:32:52 +00:00
d8bf6abc2b Isolate source directive processing 2026-10-25 22:30:46 +00:00
4bf940c579 Use deferred acquisition consistently 2026-10-25 22:28:35 +00:00
4fd3bee986 Strengthen composition and import selection tests 2026-10-25 22:21:51 +00:00
6f6130fd12 Use production directive extraction in import tests 2026-10-25 22:19:38 +00:00
3122e2f32d Remove unread private transport members 2026-10-25 22:18:12 +00:00
e4a5ecb4be Remove disconnected facade clone scaffolding 2026-10-25 22:13:00 +00:00
826062aa17 Test public historical path enumeration 2026-10-25 22:09:55 +00:00
08f2a8f3e9 Cache approved dynamic descriptors per operation 2026-10-25 22:07:37 +00:00
12d350a292 Enforce the retained history node limit 2026-10-25 22:04:54 +00:00
4fcff5f453 Prepare inspection history before publication 2026-10-25 21:53:24 +00:00
247a12bc50 Remove unused combination evidence and helpers 2026-10-25 21:49:52 +00:00
119b42c810 Remove unused history document copies 2026-10-25 21:47:53 +00:00
0f9ecf12ed Share link dependency evaluation with host access 2026-10-25 21:45:33 +00:00
7bf2f0c687 Memoize completed native link dependencies 2026-10-25 21:43:26 +00:00
8aba43da38 Preserve directory traversal failure phases 2026-10-25 21:33:36 +00:00
877c497325 Close owned import roots during panic unwinding 2026-10-25 21:27:47 +00:00
08a8c25542 Close callback contexts when callbacks panic 2026-10-25 21:25:57 +00:00
ea27f17f5c Check projection budgets before container allocation 2026-10-25 21:25:00 +00:00
ae3408a096 Distinguish finite slice views from cycles 2026-10-25 21:22:01 +00:00
deb9aa5c69 Roll back failed dynamic descriptor builds 2026-10-25 21:20:41 +00:00
2736ddfbf2 Terminate pointer-only descriptor traversal 2026-10-25 21:18:35 +00:00
7889ebddfb Plan the audit remediation sprint 2026-10-25 20:52:11 +00:00
4a5316b7fc Describe the audit remediation target and dependencies 2026-10-25 20:25:18 +00:00
e1e94eeec7 Settle audit refactoring scope and defer follow-up work 2026-10-25 20:17:47 +00:00
176e9a5d90 Select localized audit efficiency improvements 2026-10-25 20:13:57 +00:00
23c1976ef1 Settle the initial audit remediation scope 2026-10-25 20:12:12 +00:00
aab79d9508 Consolidate repository audit findings for review 2026-10-25 20:07:46 +00:00
ecd5d5d460 Record dead code and obsolete functionality audit 2026-10-25 17:22:25 +00:00
b1c278b38b Record test coverage and effectiveness audit findings 2026-10-25 17:07:39 +00:00
eb5a82f1f3 Record security audit findings 2026-10-25 16:53:57 +00:00
b30c9f167d Record error handling audit findings 2026-10-25 16:44:08 +00:00
5925066d35 Record resource management audit findings 2026-10-25 16:37:05 +00:00
0cca633a67 Record concurrency and lifetime audit results 2026-10-25 16:25:10 +00:00
a80273433e Record maintainability and complexity audit findings 2026-10-25 16:18:07 +00:00
320d0c5f43 Record algorithmic efficiency audit findings 2026-10-25 16:05:49 +00:00
5098b56655 Record functional correctness audit findings 2026-10-25 15:52:43 +00:00
47944d22d1 Update ReleaseKit to v0.4.2 2026-10-25 15:37:32 +00:00
184 changed files with 8692 additions and 2723 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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 = &copy
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
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View 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