72 Commits

Author SHA1 Message Date
d211537873 Remove completed roadmap documents 2026-11-01 19:05:56 +00:00
6db441893d Make consumer documentation easier to follow 2026-11-01 19:04:48 +00:00
e74c26a4b3 Catalog readability improvements for consumer documentation 2026-11-01 18:36:52 +00:00
08bb3a20c6 Share secret traversal between resolution and validation 2026-11-01 18:23:50 +00:00
b3bb57eda0 Record shared abstraction audit findings 2026-11-01 18:02:15 +00:00
94d1e333d4 Correct discovery documentation and test commands 2026-11-01 17:53:39 +00:00
ff10d03978 Record documentation audit findings 2026-11-01 17:48:52 +00:00
b4477a236a Update copyright statement to remove unnecessary language 2026-11-01 14:34:04 +00:00
d818b08556 Record successful remote vanity verification 2026-11-01 14:24:36 +00:00
587f407a32 Migrate ConfigKit to its vanity module path 2026-11-01 14:19:37 +00:00
335bd7bc31 Settle the Forge publication endpoint 2026-11-01 14:04:15 +00:00
331195202e Document the vanity module migration roadmap 2026-11-01 14:02:28 +00:00
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
227 changed files with 6579 additions and 3873 deletions

View File

@@ -18,8 +18,3 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details. GNU Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms. Third-party code and assets are subject to their respective license terms.
Policy documents adapted from Standard Repository Policies, the root AGENTS.md
orientation, and the Policy Reading Guide in docs/development.md remain licensed
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
upstream credit, and license links.

137
README.md
View File

@@ -1,56 +1,18 @@
# ConfigKit # ConfigKit
An opinionated Go library for applications that use YAML configuration files. ConfigKit loads YAML into your application's Go configuration type, combines the
inputs you choose, and lets you inspect where the final values came from. You
supply the types, defaults, and application rules; ConfigKit handles loading,
combination, strict decoding, and inspection.
Linux is release-validated; macOS support is best-effort. See the Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details. [platform support policy](docs/policy/release.md#platform-support).
ConfigKit's simple `Load` accepts one strict YAML source (or explicit ## Load a configuration file
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
source groups with an independently selected main input. Sources can be host
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
Enabled explicit imports compose strictly within their group; precedence
between groups combines mappings recursively. Both entry points return a complete,
validated configuration with detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply after inheritance and typed decoding, before application
resolution. Value explanation is available through
detached structured records and deterministic redacted output. Built-in
`extends` relationships let a mapping inherit from a named sibling during the
same load, retaining explicit presence and source/derivation history. The root
`Diff` API compares frozen effective values from two successful same-type loads;
the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
Sources can also be explicitly placed as sequence items. Configuration
declarations and application registrations can give selected sequences scalar
identities and choose how they combine across precedence and inheritance.
Strict peers combine distinct records, while higher-priority collections can
replace, append, or upsert whole records. See the
[sequence contract](docs/config.md#strict-document-composition),
[maintained catalog examples](docs/consumers/README.md#identified-prompt-sequences),
and [ADR 0017](docs/adr/0017-collection-sequence-policy.md).
Imports are also explicit: a source may name files, search declared directories,
or select files with path globs. Applications can set load-wide import defaults
while keeping declaration-specific exceptions. Files are acquired in authored
declaration order; dependency-ready composition uses a shared stable scheduler.
ConfigKit does not search conventional application paths implicitly. See the
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
[ADR 0016](docs/adr/0016-independent-import-controls.md).
Main-file discovery is explicit too: applications choose and order `Search`
descriptions and pass `SearchConfig` to the existing load APIs. See the
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
```go ```go
import "go.maximumdirect.net/configkit"
type Config struct { type Config struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"` Workers int `yaml:"workers"`
@@ -65,48 +27,57 @@ func ReadConfig(path string) (Config, error) {
} }
``` ```
Existing consumers should follow the
[module-path migration guidance](docs/consumers/README.md#vanity-module-path-migration).
## Add the features you need
Start with one file. Optional features use the same loading and inspection model:
- [Choose inputs](docs/consumers/README.md#choosing-a-loading-entry-point): read
files, an application-owned `fs.FS`, bytes, a reader, or a Go mapping. Use
explicit search descriptions when the application should look in several places.
- [Supply lower-priority settings](docs/consumers/README.md#native-filesystem-and-layered-input):
use `LoadLayered` for bundled defaults that application configuration can override.
- [Combine configuration](docs/config.md#explicit-imports): add declared imports,
select one overlay, or pass values explicitly set by your CLI parser.
- [Reuse settings](docs/config.md#sibling-inheritance): let one named entry inherit
from a sibling. [Identified lists](docs/config.md#strict-document-composition)
also support record inheritance and replace, append, or upsert operations.
- [Apply application rules](docs/config.md#recorded-application-resolution): derive
or normalize values in a resolver, then check the final configuration in a validator.
- [Handle credentials](docs/config.md#typed-secret-references): use environment-name
references and choose which configuration fields to hide in inspection output.
- [Inspect a load](docs/config.md#layered-metadata): examine sources, presence,
history, safe YAML output, or [changes between two loads](docs/config.md#effective-configuration-diffs).
The [consumer guide](docs/consumers/README.md) walks through these tasks and links
to maintained examples. The [configuration reference](docs/config.md) defines the
exact YAML, combination, and lifecycle rules.
## Design philosophy ## Design philosophy
ConfigKit favors one coherent, predictable configuration model over flexibility ConfigKit favors consistent, predictable rules across applications. A load returns
for reproducing each consumer's conventions. Its goal is to let applications the final configuration and metadata that distinguish supplied values such as
load once, inspect where final values came from, and distinguish explicit values `false` and `0` from omissions. Applications keep their domain rules and runtime
from omissions with minimal configuration bookkeeping. Applications retain their initialization.
domain rules and execution responsibilities.
Loading is synchronous and complete on return. Applications normally load once Within one layer, independent contributions combine strictly: conflicting
near startup and then use the final configuration and metadata; ConfigKit stays values are errors. Across layers, higher-priority settings can replace
inactive until explicitly called again. Applications may initiate additional lower-priority settings, while mappings combine by key. Identified lists have
loads as needed. See the [loading lifecycle](docs/config.md#completion-and-resource-lifetime) their declared combination rules. See the [worked combination
for completion and ownership details. examples](docs/config.md#strict-document-composition) for the details and
exceptions.
Within a configuration tier, independent contributions compose strictly: shared Loading finishes before the call returns. ConfigKit then stays inactive until you
mappings combine, while overlapping non-mapping definitions are errors. Between call it again; inspection uses the saved result. See
precedence tiers, mappings combine recursively, retaining omitted lower-priority [completion and resource lifetime](docs/config.md#completion-and-resource-lifetime).
children; other values replace lower-priority values. The main document and its
imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay applies before inheritance, while
explicit CLI overrides apply after decoding and before application resolution.
The [configuration contract](docs/config.md#strict-document-composition) owns
the detailed combination rules and input-specific constraints. Declared or
registered collections combine distinct identified records within strict tiers
and use their selected operation at precedence boundaries; other sequences keep
their strict-conflict and whole-value-replacement behavior.
The [architecture goals](docs/policy/architecture.md#success-criteria) explain The [architecture goals](docs/policy/architecture.md#success-criteria) and
these priorities and how genuine new needs can justify extending the shared [ADRs](docs/adr) explain design decisions, including shared import and inheritance
model. The [future feature list](docs/roadmap/future.md) preserves candidates for ordering. [Future work](docs/roadmap/future.md) lists possible extensions.
consideration in future releases. Contributors can start with the [development guide](docs/development.md);
release maintainers use the [release procedure](docs/release.md).
The [configuration contract](docs/config.md) describes implemented behavior,
including [input destinations](docs/config.md#input-destinations). The
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
layered, mounted-collection, and identified-prompt examples alongside the
migration shapes. Input placement decisions are recorded in
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
See the [development guide](docs/development.md) for repository orientation and
the [release procedure](docs/release.md) for maintainer-only candidate and
publication steps.
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT). ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

@@ -1,6 +1,6 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "go.maximumdirect.net/configkit/internal/engine"
func fromEngineLocation(value engine.Location) Location { func fromEngineLocation(value engine.Location) Location {
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column} return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
@@ -46,21 +46,21 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ return SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), DestinationIsFallback: engine.SourceInfoDestinationUnresolvedForFacade(value),
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionEvidence: fromEngineSourceVersionEvidence(value.VersionCheck),
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)), EmptyImportSelections: fromEngineEmptyImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
} }
} }
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection { func fromEngineEmptyImportSelections(values []engine.ImportSelectionForFacade) []EmptyImportSelection {
if values == nil { if values == nil {
return nil return nil
} }
result := make([]ImportSelection, len(values)) result := make([]EmptyImportSelection, len(values))
for index, value := range values { for index, value := range values {
result[index] = ImportSelection{ result[index] = EmptyImportSelection{
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location), Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome), Selector: ImportSelector(value.Selector),
} }
} }
return result return result
@@ -82,18 +82,18 @@ func toEngineOverrides(values []Override) []engine.Override {
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults { func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
return engine.ImportDefaults{ return engine.ImportDefaults{
Required: value.Required, Required: value.Required,
Follow: value.Follow, Follow: value.FollowSymlinks,
Hidden: value.Hidden, Hidden: value.IncludeHidden,
Recursive: value.Recursive, Recursive: value.Recursive,
Pattern: value.Pattern, Pattern: value.Pattern,
} }
} }
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { func fromEngineSourceVersionEvidence(value *engine.SourceVersionCheck) *SourceVersionEvidence {
if value == nil { if value == nil {
return nil return nil
} }
return &SourceVersionCheck{ return &SourceVersionEvidence{
Path: fromEnginePath(value.Path), Path: fromEnginePath(value.Path),
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text}, Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted, Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,

View File

@@ -3,7 +3,7 @@ package configkit
import ( import (
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine" "go.maximumdirect.net/configkit/internal/engine"
) )
// LoadError reports safe structured diagnostics for a failed configuration load. // LoadError reports safe structured diagnostics for a failed configuration load.
@@ -50,6 +50,21 @@ func (e *LoadError) Sources() []SourceInfo {
return result 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 // WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes. // control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {

22
diff.go
View File

@@ -1,15 +1,14 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "go.maximumdirect.net/configkit/internal/engine"
// Difference describes one canonical changed region between two successful // Difference describes one changed value or subtree between two successful loads.
// loads. Left is the earlier value and Right is the later value. Each snapshot // Left is the earlier value and Right is the later value. Snapshots are copies;
// is detached; Present distinguishes absence from an explicit null, and // Present distinguishes absence from explicit null. Redacted reports hiding
// Redacted reports whether combined sensitivity affected that side. Mappings // based on sensitivity from either load. Mappings compare by key; a changed
// are compared recursively, while sequences are reported as atomic values. // sequence is reported as a whole value. Records are ordered by canonical JSON
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that // pointer. HiddenChanged reports an actual hidden change, without identifying
// a value concealed by combined sensitivity changed, without revealing which // the hidden member. Redaction alone does not set it.
// concealed member changed.
type Difference struct { type Difference struct {
Path Path Path Path
Left ValueSnapshot Left ValueSnapshot
@@ -17,8 +16,9 @@ type Difference struct {
HiddenChanged bool HiddenChanged bool
} }
// Diff compares the frozen effective values of two successful loads of the // Diff compares the saved final values of two successful loads of the same
// same configuration type. It does not reread sources, environment variables, // configuration type, independently of later changes to Result.Config.
// It does not reread sources, environment variables,
// or rerun defaults or resolvers. Equal values produce no records even when // or rerun defaults or resolvers. Equal values produce no records even when
// their sources, defaults, or resolution histories differ. Credential contents // their sources, defaults, or resolution histories differ. Credential contents
// are not compared: typed secret references compare by reference name only. // are not compared: typed secret references compare by reference name only.

View File

@@ -1,6 +1,6 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" import "go.maximumdirect.net/configkit/internal/engine"
// Search is an immutable ordered description of possible host-file inputs. // Search is an immutable ordered description of possible host-file inputs.
// It is consumed only when passed to SearchConfig and used by a load. // It is consumed only when passed to SearchConfig and used by a load.
@@ -52,8 +52,8 @@ func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Searc
return Search{value: engine.SearchDirectory(directory, options...)} return Search{value: engine.SearchDirectory(directory, options...)}
} }
// Paths describes literal host-file candidates in the supplied order. // SearchPaths describes literal host-file candidates in the supplied order.
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} } func SearchPaths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
// Searches concatenates descriptions in order and snapshots their entries. // Searches concatenates descriptions in order and snapshots their entries.
func Searches(searches ...Search) Search { func Searches(searches ...Search) Search {

22
doc.go
View File

@@ -1,9 +1,17 @@
// Package configkit provides deterministic, application-owned configuration // Package configkit loads YAML into an application's Go configuration type and
// loading for Go programs. // lets the application inspect where the final values came from.
// //
// Load supports file, byte, reader, and explicit no-input sources with strict YAML, // Start with Load and a File, Bytes, Reader, Mapping, or Filesystem.File source.
// typed defaults, imports, overlays, CLI overrides, schema-version checks, detached // Use NoSource for deliberately absent input or SearchConfig for an explicitly
// metadata, recorded application resolution, typed secret references, safe effective // chosen search. Applications supply types, defaults, version rules, and callbacks
// rendering, read-only semantic validation, and structured load errors. The // for deriving and validating values. LoadLayered adds lower-priority input groups.
// configuration contract is documented in docs/config.md. //
// A successful load returns the final configuration and saved inspection state.
// Metadata tracks supplied values, including false and zero; explanations retain
// safe earlier values. WriteEffective and Diff use the saved state even if the
// application later changes Result.Config. Secret references resolve credentials
// during loading, while inspection follows the configured hiding rules.
//
// The consumer guide is in docs/consumers/README.md. The exact YAML, combination,
// lifecycle, and ownership rules are documented in docs/config.md.
package configkit package configkit

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,35 @@
# Consumer Adoption Guide # Consumer Adoption Guide
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and ConfigKit loads configuration into your application's Go types. You provide
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from those types, defaults, and application rules. ConfigKit reads and combines the
applications. Consumers still own their configuration structs, defaults, semantic selected inputs, checks their types, and returns values with source information.
validation, schema-version policy, CLI parsing, runtime matching and conversion, Your application still owns CLI parsing, resource reads, remote acquisition, and
resource checks, remote acquisition, and service initialization. runtime initialization.
Import `"go.maximumdirect.net/configkit"`; the package name is `configkit`.
Start with one file, then choose the tasks your application needs:
| Task | Start here |
| --- | --- |
| Load one file | [Happy path](#happy-path) and the [README example](../../README.md#load-a-configuration-file) |
| Supply fixed defaults and check application rules | [Defaults and application rules](#defaults-and-application-rules) |
| Add bundled defaults beneath application settings | [Native filesystem and layered input](#native-filesystem-and-layered-input) |
| Select files or an overlay | [Discovery](#explicit-main-file-discovery), [import defaults](#import-defaults-and-selection-inspection), and the [overlay reference](../config.md#selected-overlays) |
| Reuse settings or combine record lists | [Inheritance](#named-sibling-inheritance) and [identified sequences](#identified-prompt-sequences) |
| Handle credentials | [Typed-secret reference](../config.md#typed-secret-references) and the [startup example](../../examples/consumers/consumers_test.go) |
| Inspect sources, earlier values, or changes | [Inspect a completed load](#inspect-a-completed-load) and [diffs](#effective-configuration-diffs) |
| Upgrade an existing consumer | [Migration guidance](#intentional-migration-changes) |
## Choosing a loading entry point ## Choosing a loading entry point
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration. - Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
[`SearchConfig`](../../discovery.go) is also a source, so its explicit [`SearchConfig`](../../discovery.go) is also a source; pass it to `Load` or use
discovery result can be passed to `Load` or used as the main input to it as the main input to [`LoadLayered`](../../layered.go).
[`LoadLayered`](../../layered.go). - Use `LoadLayered` when several inputs should supply lower-priority defaults
- Use `LoadLayered` when inputs must be composed in underlay groups before the beneath the main source. Each underlay group combines its inputs strictly.
main source. Each [`Input`](../../layered.go) owns its source, filesystem, Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
destination, and import choice. The overlay filesystem context is selected import choice. The overlay filesystem context is selected independently in
independently in [`LayeredOptions`](../../layered.go). [`LayeredOptions`](../../layered.go).
- Load-wide rules belong to [`Options`](../../lifecycle.go) or - Load-wide rules belong to [`Options`](../../lifecycle.go) or
[`LayeredOptions`](../../layered.go): the application definition (including [`LayeredOptions`](../../layered.go): the application definition (including
version policy), import defaults, and explicit overrides. These do not turn version policy), import defaults, and explicit overrides. These do not turn
@@ -30,68 +44,68 @@ file and layered cases. For import-focused adoption, see the
discovery composed with sequences, see the discovery composed with sequences, see the
[sequence catalog](../../examples/consumers/sequence_catalog.go). [sequence catalog](../../examples/consumers/sequence_catalog.go).
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
The Notarius-shaped fixtures deliberately load both `version: 4` and
`version: "4"` into the same string-backed application field. Its version checker
still owns the supported identifier; fractional numeric input and unsupported
integer identifiers remain negative examples.
## Happy path ## Happy path
An application defines ordinary types and passes one explicit source. Optional Define the Go configuration type, choose one source, and call `Load`. Handle a
features appear only when the application needs them: load error before starting the application with `result.Config`. The
[README example](../../README.md#load-a-configuration-file) shows this complete
minimal loading function. An explicitly missing file is an error; use `NoSource`
when you deliberately want defaults without a main document.
### Defaults and application rules
Using the README's `Config` type, an application can add reusable rules:
```go ```go
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{ definition := configkit.Definition[Config]{
Definition: rules, Defaults: func() Config { return Config{Enabled: true, Workers: 2} },
Imports: useDeclaredImports, Validate: func(value *Config, validation *configkit.Validation) {
Overlay: selectedConfigOverlay, if value.Workers < 0 {
Overrides: explicitlySetFlags, validation.Error(configkit.Keys("workers"), "must be nonnegative")
}) }
if err != nil { },
return err
} }
return start(result.Config)
``` ```
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into Pass this definition in `Options[Config]{Definition: definition}` when calling
both without redefining their syntax. ConfigKit overlays are configuration overlays; `Load`. Supplied `enabled: false` or `workers: 0` replaces those defaults.
they do not select or replace an application's LLM, model, pipeline, report, or Use `Resolve` when a value must be derived or normalized before validation, and
execution profile concepts. record that change with the provided helpers. The
[callback reference](../config.md#recorded-application-resolution) compares the
helpers; [executable examples](../../example_test.go) show full definitions.
### Inspect a completed load
Keep the result when you need to explain configuration. Use
`result.Metadata().Lookup(configkit.Keys("workers"))` to inspect whether input
supplied the value and which source or application step supplied it. Follow an
origin's source ID with `result.Source(id)` when you need its source record.
Use `Explain` for earlier values, `WriteExplanation` for a readable explanation,
and `WriteEffective` for safe YAML describing the final saved state. These
operations describe the completed load even if your application later changes
`result.Config`. The [inspection reference](../config.md#layered-metadata)
explains returned values, copies, and hidden information.
Add imports, a selected overlay, or explicitly set CLI values through options
only when needed. An overlay changes configuration; choosing an LLM, report, or
execution profile remains application logic.
## Explicit main-file discovery ## Explicit main-file discovery
Applications choose the search conventions and their precedence, then pass the Applications choose the search conventions and their precedence, then pass the
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point. resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry
For example, the maintained sequence catalog puts a caller-selected file before point. For example, the maintained sequence catalog puts a caller-selected
a custom directory fallback while keeping its underlay and overlay choices file before a custom directory fallback while keeping its underlay and overlay
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go) choices independent. See
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go) and its
the [working-directory executable example](../../examples/consumers/discovery_test.go). [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus the
Together, the root-owned [discovery API](../../discovery.go) and [configuration [working-directory executable
discovery contract](../config.md#configuration-discovery) define ordered example](../../examples/consumers/discovery_test.go). Together, the [discovery
concatenation, directory-first Cartesian expansion, optional exhaustion, and API](../../discovery.go) and [configuration discovery
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md) contract](../config.md#configuration-discovery) explain the order of attempted
files, what happens when no file is found, and why a selected-file failure
stops the search. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
records why discovery is explicitly selected. records why discovery is explicitly selected.
## Import defaults and selection inspection ## Import defaults and selection inspection
@@ -99,15 +113,20 @@ records why discovery is explicitly selected.
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one `Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
load-wide policy to enabled main, underlay, and transitive imports. Each field load-wide policy to enabled main, underlay, and transitive imports. Each field
is resolved independently: a declaration's explicit value wins, then the is resolved independently: a declaration's explicit value wins, then the
application default, then the library default. `Required`, `Follow`, `Hidden`, application default, then the library default. `Required`, `FollowSymlinks`,
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to `IncludeHidden`, and `Recursive` use nil pointers for unspecified values.
`Pattern` applies to
directory searches: nil leaves filenames unrestricted, while a non-nil empty directory searches: nil leaves filenames unrestricted, while a non-nil empty
slice selects no files. A declaration's own `pattern` replaces the application slice selects no files. A declaration's own `pattern` replaces the application
default. The defaults do not enable imports or change selected-overlay behavior. 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 `Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
declaring source's `ImportSelections`. These records identify the source-local declaring source's `EmptyImportSelections`. `Result.Source(id)` and
declaration path, its authored location and selector, and the `none` outcome; `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 they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports) records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics. owns selector applicability and exact absence semantics.
@@ -117,7 +136,7 @@ owns selector applicability and exact absence semantics.
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates [`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
if-present and required modes with one `Definition.Version` rule across a mounted if-present and required modes with one `Definition.Version` rule across a mounted
main input and an underlay. The example reads only sanitized 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 [`layered_test.go`](../../examples/consumers/layered_test.go). The
[configuration contract](../config.md#shared-source-version-checks) defines [configuration contract](../config.md#shared-source-version-checks) defines
participation, source-local lookup and diagnostics, non-main header consumption, participation, source-local lookup and diagnostics, non-main header consumption,
@@ -135,13 +154,14 @@ priority values remain present. Its resolver records a derived endpoint URL afte
overlay application, and its nested typed secret reference is classified before overlay application, and its nested typed secret reference is classified before
the resulting records are presented. the resulting records are presented.
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go) The maintained [behavior tests and executable
use temporary files and disposable secret values. Presenters should use examples](../../examples/consumers/diff_test.go) use temporary files and
`ValueSnapshot.Present` for absence, record membership for change, and disposable secret values. Presenters should use `ValueSnapshot.Present` for
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker absence, record membership for change, and `HiddenChanged` for a withheld
or add separately rendered full contexts that lack combined sensitivity. The change indicator. Do not parse the redaction marker or add separately rendered
[configuration contract](../config.md#effective-configuration-diffs) owns shared full contexts that lack combined sensitivity. The [configuration
equality, granularity, and disclosure semantics. contract](../config.md#effective-configuration-diffs) owns shared equality,
granularity, and disclosure semantics.
## Named sibling inheritance ## Named sibling inheritance
@@ -264,14 +284,13 @@ conversion API.
## Source-aware application path policy ## Source-aware application path policy
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained [`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 `Resolution.Source` before normalizing: relative CLI input uses an explicitly
supplied invocation directory, retained defaults use the canonical main source, supplied invocation directory, retained defaults use the canonical main source,
and a file-authored scalar uses its one declaring source. Absolute paths remain and a file-authored scalar uses its one declaring source. Absolute paths remain
unchanged. Its executable test covers the matching effective YAML and explanation unchanged. Its executable test checks the resulting YAML and explanation
history, source-file ownership, and a symlink opened through an alias; canonical history. When the configuration filename is a symlink, this example uses the
target-file directories, rather than the alias directory or process working actual target file's directory as the base for file-authored paths.
directory, are the selected bases.
The selected example policy leaves a relative value unchanged and emits a warning The selected example policy leaves a relative value unchanged and emits a warning
when a CLI base is absent or file provenance is missing or composite. That is an when a CLI base is absent or file provenance is missing or composite. That is an
@@ -281,7 +300,15 @@ existence checks nor confinement checks.
## Intentional migration changes ## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention: When upgrading, start with the changes relevant to your dependency and inputs:
1. Update the module requirement and Go imports together as described below.
2. Replace old public names using the old/new table.
3. Update any layer or diagnostic-code strings stored or compared by your code.
4. Review YAML and startup changes that apply to your application, then rebuild
and run its configuration tests.
The maintained examples illustrate these configuration changes:
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional. - Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
@@ -301,10 +328,70 @@ The examples preserve domain meaning while adopting one configuration convention
parsers translate only explicitly set configuration flags into `Override` records. parsers translate only explicitly set configuration flags into `Override` records.
Separate invocation state remains outside configuration. Separate invocation state remains outside configuration.
- Credential fields migrate to environment-name `Secret` references. Applications - 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 blocked empty variables remain blocked, and ordinary `Load` never changes the
process environment. process environment.
### Vanity module path migration
ConfigKit's module identifier changed from
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit` before
v1.0.0. Update the dependency requirement in your application's `go.mod` and its
Go imports together, selecting a revision or release that declares the new module
path. The package name remains `configkit`; the configuration model and public
symbol names are unchanged by this identity migration.
Go treats the old and new import paths as different packages, with different
named types. For example, if another library returns a ConfigKit `Path` or
`Secret` using the old import, your application cannot use it as the new
package's type just by changing a module requirement. Coordinate migration
with dependencies that pass ConfigKit values to or from your application. The
maintained examples use the new path, but other repositories must manage their
own migrations.
Historical tags keep their original module declarations and contents. Older
releases such as v0.10.2 still use the old path and cannot be installed under the
vanity identifier simply because discovery can find their repository. A matching
migrated revision is needed; no old-path compatibility module is supplied here.
### 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. If your application compares or
stores layer names or diagnostic codes as strings, update those strings
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 ### Selected-overlay migration
To adopt the current selected-overlay API, rename `Options.Profile`, To adopt the current selected-overlay API, rename `Options.Profile`,
@@ -315,10 +402,40 @@ The [configuration contract](../config.md#selected-overlays) is the canonical
behavioral reference. Application-owned profile concepts, such as `model_profile`, behavioral reference. Application-owned profile concepts, such as `model_profile`,
are unchanged, and any CLI flag rename remains the application's decision. are unchanged, and any CLI flag rename remains the application's decision.
These examples are migration evidence, not completed changes to Narratio, Notarius, ### Background on the examples
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
wiring, runtime catalogs, and operational documentation remains separate downstream These examples demonstrate migration shapes. They do not claim that Narratio,
work. Notarius, WeatherReporter, or Seriatim has been migrated. Updating those
repositories, their deployed YAML, CLI wiring, runtime catalogs, and
operational documentation remains separate downstream work.
## Example background
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Artifact defaults and normalization (Narratio-like) | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Versions and dynamic options (Notarius-like) | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| Module validation and credential startup (WeatherReporter-like) | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Independent ordered rule files (Seriatim-like) | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
The Notarius-shaped fixtures deliberately load both `version: 4` and
`version: "4"` into the same string-backed application field. Its version checker
still owns the supported identifier; fractional numeric input and unsupported
integer identifiers remain negative examples.
## Maintained API examples ## Maintained API examples
@@ -330,7 +447,7 @@ focused behavioral owner:
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) | | `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 | | 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 | | 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 | | 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` | | 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` | | Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |

View File

@@ -43,7 +43,7 @@ public behavior.
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below | | Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
| Documentation changes | The owning document | | Documentation changes | The owning document |
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure | | Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
| Architecture decisions or future work | [Future features](roadmap/future.md), [consolidated audit review](roadmap/audit.md), [remediation roadmap](roadmap/roadmap.md), and relevant ADRs listed below | | Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
| Code and test changes | The owning contract and tests from the map below | | Code and test changes | The owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) | | Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release procedure](release.md) | | Release preparation | [Release procedure](release.md) |
@@ -199,11 +199,32 @@ git show 34cbc0d:docs/roadmap/remediation.md
``` ```
Potential enhancements are tracked in [future features](roadmap/future.md). Potential enhancements are tracked in [future features](roadmap/future.md).
The [consolidated audit review](roadmap/audit.md) owns the open findings from The completed audit-remediation, internal-boundary simplification, shared-history,
the nine 2026-10-25 audits, their evidence, and settled scope decisions. The and public API cleanup roadmaps, reviews, and resolved findings are preserved at
[remediation roadmap](roadmap/roadmap.md) owns the combined target state and `68400ff` under `docs/roadmap/`. For example:
high-level dependency order. The original reports are preserved at `ecd5d5d`
under `docs/roadmap/`. ```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. Install the Go version required by `go.mod` or a compatible newer toolchain.
Acquire declared dependencies explicitly before entering an offline Acquire declared dependencies explicitly before entering an offline
@@ -252,21 +273,21 @@ code changes. Use the component overview to find the corresponding implementatio
| --- | --- | --- | | --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` | | Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery\|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset\|SequenceCatalog'` |
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` | | Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import\|ScheduleDependencies\|Inheritance'`, `go test . -run 'ImportDefaults\|FacadeImport'`, and `go test ./examples/consumers` |
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` | | Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` | | Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` | | Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
| Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` | | Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` | | Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` | | Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination\|Import\|Glob\|Mounted'`, `go test . -run 'Destination\|Import\|SourceContext\|FacadeMounted'`, and `go test ./examples/consumers` |
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` | | Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` | | Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` | | Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` | | Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff\|Explain\|History\|Secret\|Independent'`, `go test . -run 'Diff\|FacadeInspection\|PublicFacadeBoundary\|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | | Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` | | Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` | | Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |

View File

@@ -151,6 +151,27 @@ descriptor caching and type policy, and
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
those descriptors for typed decoding. 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 Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
covered by `imports_test.go`, `load_request_test.go`, and focused source, covered by `imports_test.go`, `load_request_test.go`, and focused source,
discovery, and destination tests; inheritance by the inheritance test files; discovery, and destination tests; inheritance by the inheritance test files;
@@ -158,3 +179,9 @@ reflection behavior by `schema_test.go`, `projection_test.go`, and
`decode_test.go`. The [development test map](../development.md#task-to-test-map) `decode_test.go`. The [development test map](../development.md#task-to-test-map)
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
copying, using the shared descriptors and per-operation traversal helpers. copying, using the shared descriptors and per-operation traversal helpers.
Each schema operation builds its private reflection descriptor graph with
operation-local caching; there is no global mutable descriptor cache. Decoding,
projection, and cloning reuse these descriptors so field bindings agree. The
public reference describes the resulting type, output, and ownership behavior;
this descriptor storage is an implementation detail.

View File

@@ -1,16 +1,23 @@
# ConfigKit Release Procedure # ConfigKit Release Procedure
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md). This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
ConfigKit publishes a source-only Go module. The immutable annotated tag and A release publishes the Go source under a new Git tag, with a matching release
matching checked-in release note are the deliverables; there is no binary, note committed in the repository. The tag is annotated: it includes the release
container image, generated package, or separate registry publication. note text and points to the released commit. Published tags and contents are
immutable. There is no binary, container image, generated package, or separate
registry publication.
A **candidate** is the committed revision you intend to release. The
**authoritative remote** is the repository where ConfigKit publishes releases;
its exact push URL is listed below. The **consumer minimum** is the oldest Go
version supported for applications using ConfigKit.
## Release identity and support ## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit` - Module: `go.maximumdirect.net/configkit`
- Primary branch: `main` - Primary branch: `main`
- Authoritative remote: `origin` - Authoritative remote: `origin`
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git` - Authoritative push URL: `ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git`
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases - Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
- Minimum Go version: the first stable patch of the selected Go series, or a - Minimum Go version: the first stable patch of the selected Go series, or a
higher patch when required by a dependency or specific correctness fix, as higher patch when required by a dependency or specific correctness fix, as
@@ -20,10 +27,15 @@ container image, generated package, or separate registry publication.
- Best-effort targets: macOS amd64 and arm64, under the - Best-effort targets: macOS amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support) [platform support policy](policy/release.md#platform-support)
Release validation runs one offline race-enabled Go suite, one full vet pass, Release validation runs the offline Go test suite once with the race detector,
and CGO-disabled package compilation for both Linux targets. macOS and CI are then a full vet pass and package compilation for both Linux targets with cgo
disabled (`CGO_ENABLED=0`). macOS and CI are
not release gates; do not wait for or poll CI. not release gates; do not wait for or poll CI.
The vanity module path is the name applications put in Go imports. Source/tag
browser links and public Go retrieval use the Forge HTTPS repository
independently of SSH publication.
## 1. Select Go and prepare the tool ## 1. Select Go and prepare the tool
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
@@ -33,10 +45,10 @@ For a patch release, retain the series recorded in `go.mod` and leave its consum
minimum unchanged. Follow the minimum unchanged. Follow the
[toolchain selection policy](policy/release.md#configkit-toolchain-selection). [toolchain selection policy](policy/release.md#configkit-toolchain-selection).
Put the selected series' latest locally available compatible patch first on Choose the latest compatible patch installed locally in that Go series. Put
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build its `go` executable first on `PATH` and ensure it meets the minimum in
ReleaseKit and run its child validation commands. Set local toolchain selection `go.mod`. The same `go` executable must build ReleaseKit and run its child
and verify it: validation commands. Set local toolchain selection and verify it:
```sh ```sh
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
@@ -61,8 +73,17 @@ use that required version instead and document the reason. When the series is
unchanged, leave the minimum unchanged unless a minor or major release unchanged, leave the minimum unchanged unless a minor or major release
deliberately changes it under the deliberately changes it under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain). [consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
For example, validating with Go 1.26.8 leaves `go 1.26.0` unchanged. There is no For example:
separate requirement to validate with Go 1.26.0.
| Release situation | Go used for validation | Consumer minimum |
| --- | --- | --- |
| Patch release in the 1.26 series | Latest compatible patch installed locally, such as 1.26.8 | Keep the existing `go.mod` minimum unchanged. |
| Minor/major release staying in 1.26 | Latest compatible patch installed locally | Normally retain the minimum; any deliberate change follows the linked policy. |
| Minor/major release adopting a newer installed stable series | Latest compatible patch installed locally in that series | Normally declare that series' initial stable patch, or the higher patch required by a dependency or identified fix. |
Using Go 1.26.8 for validation can therefore leave `go 1.26.0` unchanged. Users
need the declared minimum, while maintainers validate with their selected patch.
There is no separate requirement to validate with Go 1.26.0.
Prepare dependencies whether or not the minimum changes: Prepare dependencies whether or not the minimum changes:
@@ -70,7 +91,8 @@ Prepare dependencies whether or not the minimum changes:
go mod download go mod download
``` ```
Build the tool selected by `go.mod` into temporary storage outside the checkout. Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
the checkout.
Ensure `$TMPDIR` names a system temporary location, not the repository. These Ensure `$TMPDIR` names a system temporary location, not the repository. These
commands never install another ReleaseKit version: commands never install another ReleaseKit version:
@@ -82,8 +104,8 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version "$releasekit_bin" version
``` ```
The module-selected executable is ReleaseKit v0.4.2. Use the built executable This repository selects ReleaseKit v0.4.2. Use the compiled program above for
for publication to preserve its exit status and publication-state diagnostics. publication so its exit status and publication report reach you unchanged.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands `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 use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
executable first on `PATH` through the publication command. executable first on `PATH` through the publication command.
@@ -91,7 +113,7 @@ executable first on `PATH` through the publication command.
Verify access to the authoritative remote without displaying credentials: Verify access to the authoritative remote without displaying credentials:
```sh ```sh
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main git ls-remote ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD refs/heads/main
``` ```
Publication requires explicit authorization. Local implementation commits do Publication requires explicit authorization. Local implementation commits do
@@ -99,10 +121,10 @@ not authorize a push or release.
## 2. Prepare and commit the candidate ## 2. Prepare and commit the candidate
Choose an unused stable version locally and on the authoritative remote. Compare Choose a stable version that has not been used locally or on the publication
the candidate with the previous tag and review exported Go signatures and remote. Compare the candidate with the previous tag. Review public Go APIs and
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go value ownership, YAML and resolution rules, diagnostic contracts, the minimum
version, and platform support. Review maintained consumer examples and the Go version, and platform support. Review maintained consumer examples and the
migration guidance. Explain any consumer-minimum increase and required consumer migration guidance. Explain any consumer-minimum increase and required consumer
action under the action under the
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain). [consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
@@ -111,7 +133,7 @@ Create `docs/releases/<version>.md`. Its first line must be exactly
`# ConfigKit <version>`, and it must end with the license and attribution footer: `# ConfigKit <version>`, and it must end with the license and attribution footer:
```markdown ```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md) [COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/docs/oss/attribution.md)
``` ```
Replace `VERSION` with the full tag and confirm the three target files exist in Replace `VERSION` with the full tag and confirm the three target files exist in
@@ -127,7 +149,7 @@ Push only that committed candidate to the literal authoritative URL. This does
not follow local tags: not follow local tags:
```sh ```sh
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main git push --no-follow-tags ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD:refs/heads/main
``` ```
ReleaseKit requires authoritative `main` to equal this candidate before it ReleaseKit requires authoritative `main` to equal this candidate before it
@@ -139,32 +161,40 @@ candidate to `main`, and invoke validation again.
## 4. Publish the committed candidate ## 4. Publish the committed candidate
With the authorized candidate already on authoritative `main`, invoke the Once publication is authorized and the candidate is on the publication remote's
module-selected native executable exactly once: `main`, run the compiled ReleaseKit program once to validate and publish it:
```sh ```sh
"$releasekit_bin" publish vMAJOR.MINOR.PATCH "$releasekit_bin" publish vMAJOR.MINOR.PATCH
``` ```
Replace the placeholder with the selected version. ReleaseKit checks the Replace the placeholder with the selected version. ReleaseKit checks the
effective push URL against `expected_push_url`, verifies the candidate on push URL Git will actually use, after URL rewrites, against `expected_push_url`.
It verifies the candidate on
`main`, and checks the release-note H1 for the product and version. It then `main`, and checks the release-note H1 for the product and version. It then
runs the selected race-enabled standard suite once with test-time vet disabled, runs the selected race-enabled standard suite once with test-time vet disabled,
one full vet pass, and CGO-disabled package compilation for Linux amd64 and one full vet pass, and CGO-disabled package compilation for Linux amd64 and
arm64. It publishes only the annotated tag for that candidate. Confirmed arm64. It publishes only the annotated tag for that candidate. Confirmed
publication of the intended tag completes the release. The tag points to the publication of the intended tag completes the release. The tag points to the
captured commit and uses the complete checked-in note as its annotation message. captured commit and uses the complete checked-in note as its annotation message.
The optional `check` Use the optional `check` command for diagnosis when useful. It validates without
command is available for diagnosis but is not a separate required pass: publishing and is not required before `publish`, which performs its own checks:
```sh ```sh
"$releasekit_bin" check vMAJOR.MINOR.PATCH "$releasekit_bin" check vMAJOR.MINOR.PATCH
``` ```
Status 0 confirms publication, status 1 reports a known publication failure or For `publish`, interpret the program's exit status as follows:
conflict, status 2 reports usage, profile, or version errors, and status 3 means
the push outcome remains unknown. Preserve both the native exit status and its | Status | Meaning | Next action |
diagnostics. | --- | --- | --- |
| `0` | Publication succeeded. | The release is complete. Optional verification can follow. |
| `1` | A known publication failure or conflict was reported. | Read the report and correct the stated problem. Preserve any published tag. |
| `2` | The command, profile, or version is invalid. | Correct the command or configuration before trying again. |
| `3` | The tag push outcome is still unknown. | Follow the recovery procedure below before retrying or declaring success. |
Keep both the program's exit status and its diagnostic report. Using `go run`
would lose the distinction between its nonzero exit statuses.
The command does not push or repair `main`, wait for CI, or publish binaries or The command does not push or repair `main`, wait for CI, or publish binaries or
a hosted release page. A successful intended-tag publication does not require a hosted release page. A successful intended-tag publication does not require
@@ -173,12 +203,17 @@ remote read-back or consumer retrieval; those checks remain optional.
## 5. Recover from a failed or uncertain tag push ## 5. Recover from a failed or uncertain tag push
The executable reports the publication state, phase, version, candidate commit, The executable reports the publication state, phase, version, candidate commit,
tag-object ID, effective endpoint, and error on standard error. It performs one tag-object ID, effective endpoint, and error on standard error. After a failed
bounded authoritative reconciliation after a failed tag push. Exit status 3 tag push, it makes one bounded check of the exact publication remote to find out
whether the tag arrived. Exit status 3
means the tag outcome remains unknown; do not blindly retry or move a tag. means the tag outcome remains unknown; do not blindly retry or move a tag.
For an uncertain result, use the exact endpoint, candidate commit, version, and An annotated tag is a Git object containing the release note and a pointer to
tag-object ID from the report. Copy the captured endpoint into the variable the source commit. Its tag-object ID identifies that complete object. Checking
only the commit ID would not establish that the intended tag object arrived.
For an uncertain result, use the exact endpoint (remote URL), candidate commit,
version, and tag-object ID from the report. Copy the captured endpoint into the variable
below using normal shell quoting. Check that Git still resolves it to itself, below using normal shell quoting. Check that Git still resolves it to itself,
then query the authoritative refs once: then query the authoritative refs once:
@@ -195,7 +230,8 @@ cannot establish absence at the captured endpoint.
Compare the `main` object ID with the reported candidate commit and the tag Compare the `main` object ID with the reported candidate commit and the tag
object ID with the reported tag-object ID. `--refs` returns the annotated tag object ID with the reported tag-object ID. `--refs` returns the annotated tag
object itself, not merely its peeled commit. If the tag object matches, the object itself. A peeled commit is the source commit reached by following that
tag; it is a different identity. If the tag object matches, the
release is published. If the query fails, the result is conflicting, or the release is published. If the query fails, the result is conflicting, or the
endpoint or candidate differs, stop and resolve the state before retrying. If endpoint or candidate differs, stop and resolve the state before retrying. If
the endpoint is reachable, the tag is confirmed absent, and `main` still equals the endpoint is reachable, the tag is confirmed absent, and `main` still equals
@@ -228,8 +264,8 @@ consumer_dir=$(mktemp -d)
( (
cd "$consumer_dir" cd "$consumer_dir"
go mod init example.invalid/configkit-release-check go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version" GOWORK=off GOPROXY=direct go get "go.maximumdirect.net/configkit@$release_version"
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit GOWORK=off go list -m -f '{{.Path}} {{.Version}}' go.maximumdirect.net/configkit
) )
``` ```

View File

@@ -1,949 +0,0 @@
# Consolidated Audit Review
Status: scope settled; 29 entries selected for implementation planning.
Two refactors are deferred to `future.md`. Implementation has not begun.
Consolidated: 2026-10-25. Baseline: `ecd5d5d`.
This document consolidates nine sequential audits for maintainer review. It owns
their open findings, supporting evidence, proposed remedies, and relationships.
The settled decisions select the [remediation roadmap's](roadmap.md) scope.
That document owns the combined target state and high-level dependency order;
this review owns detailed finding evidence and acceptance cases. Neither is a
staged implementation plan or a new set of release gates. The
[configuration contract](../config.md), public Go declarations, and
[architecture policy](../policy/architecture.md) continue to
own behavior and guarantees.
There are nine distinct reported defects: four from correctness, three from
resources, one from error handling, and one from security. Concurrency adds
evidence for an existing callback-lifetime defect. Other entries are cleanup,
efficiency, maintainability, or test improvements. Regression recommendations
are attached to the defects they protect rather than counted as additional
product fixes.
The 35 original numbered findings map to 31 review entries after deduplication
and integration of related test work. All findings remain unresolved; the
[settled scope decisions](#settled-scope-decisions) identify the 29 selected
entries. C03 and F07 have moved to `future.md`, with reference entries retained
here for traceability. All three scope questions are settled.
Their medium/low priorities retain the original assessments; those labels do not
imply severity certification or implementation order. IDs remain stable as work
is selected or completed.
## Evidence And Provenance
All nine audits were dated 2026-10-25. Their sequential commits changed only the
audit documents, so their production baseline is the same. This consolidation
does not independently reproduce the findings or claim new runtime evidence.
| Original report | Reviewed revision | Evidence contributed |
| --- | --- | --- |
| `correctness.md` | `47944d2` | Source review and standalone public-API reproductions of four defects. |
| `efficiency.md` | `5098b56` | Source-derived scaling cases and proposed algorithm changes; no benchmarks. |
| `complexity.md` | `320d0c5` | Responsibility/caller review, graph complexity estimates, and maintainability candidates. |
| `concurrency.md` | `a802734` | Ownership review, race checks, and corroboration of callback lifetime after panic. |
| `resources.md` | `0cca633` | Bounded allocation, retained-node, and host-handle diagnostics. |
| `error-handling.md` | `5925066` | Directory read/close fault injection through the public API. |
| `security.md` | `b30c9f1` | Trust-boundary review, bounded symlink operation counts, and a historical dependency spot-check. |
| `testing.md` | `eb5a82f` | Aggregate statement coverage, assertion review, and regression/test-cleanup recommendations. |
| `deadcode.md` | `b1c278b` | Type-aware reference inventory, graph/caller checks, and source review of private cleanup candidates. |
The full original reports, including exact probes, commands, source line
references, and validation limitations, are preserved at `ecd5d5d`. For example:
```sh
git show ecd5d5d:docs/roadmap/correctness.md
git show ecd5d5d:docs/roadmap/security.md
```
The [original-finding map](#original-finding-map) accounts for every numbered
finding, including duplicates and test recommendations integrated elsewhere.
Source links below identify implementation owners; historical line numbers are
left in the originals because later edits will move them.
### Recorded Validation And Its Limits
The audits report successful development checks, ordinary tests, full race tests,
`go vet`, and focused suites as specified in their individual records, using
Go 1.26.10 on Linux amd64. Concurrency additionally repeated six relevant engine
tests with race instrumentation, count 20, and CPUs 1, 4, and 8. Testing ran the
suite with shuffle enabled three times. These results did not detect the newly
reported defects and are not evidence that those defects have been fixed.
Testing recorded 771 ordinary test functions, three fuzz targets, and 29 executable
examples. Aggregate cross-package coverage was 90.3%: root 81.3%, engine 91.0%,
document 100%, YAML 89.9%, and maintained consumers 79.2%. Those figures locate
gaps; they are not targets or a measure of assertion strength. Only checked-in
fuzz seeds ran, not an extended campaign or mutation analysis.
Standalone diagnostics stayed outside the repository. Some potentially fatal
cases used disposable processes, small limits, or operation ceilings; larger
scaling examples were derived rather than executed. The dead-code inventory
considered Linux, Darwin, and Windows build configurations, but no audit reports
native macOS or Windows execution. Graph scores and reachability results were
triage aids, not proof that a function is unnecessary or a numerical refactoring
threshold. Cached analysis tooling was used outside the repository; no new
project tools or dependencies were introduced.
The security report's module-path advisory spot-check and successful module
integrity verification are historical evidence only. It recorded no listed
advisories for the selected YAML v4, ReleaseKit, or Goldmark v2 module paths, and
recorded the selected `x/mod` version as fixed for the two reports it found. It
did not perform complete toolchain/transitive reachability analysis or supply-
chain attestation; dedicated vulnerability, security, and secret scanners were
unavailable. This consolidation neither refreshes nor broadens that conclusion.
## Review Priorities And Dependencies
Recommended review order is reported defects with their regression coverage,
bounded cleanup, targeted efficiency changes, and then broader structural
refactoring. Small prerequisite cleanups can accompany a defect fix. The order
of the groups below is for navigation, not an execution sequence.
| Relationship | Consequence for scope selection |
| --- | --- |
| A02 and A05 | Make descriptor construction safe before reusing successful dynamic-type eligibility results. |
| B01 and B02 | Panic cleanup must close callback state and the owned host root independently. Neither fix substitutes for the other. |
| C01 and its filesystem tests | Strengthen error/handle assertions while adding failure-phase regressions, using one shared test owner. |
| C02 and deferred C03 | Complete link expansion remediation independently; reconsider glob readability extraction afterward through `future.md`. |
| D01, D02, and D03 | Remove the confirmed unused copies, then account for the complete retained graph. Removing one unused tree does not fix the remaining omissions. |
| E01, E02, and E03 | Address policy-state costs and responsibility boundaries together, preserving separate scaling and behavioral acceptance cases. |
| F01 and F02 | Removing the test-only acquisition mode does not by itself simplify active lifecycle handoffs. |
| F04, F09, and F12 | Replace useful facade ownership/query coverage before deleting disconnected or representation-coupled scaffolding. |
| F05 and deferred F07 | Remove unused placement bridges in this scope; reconsider wider transport simplification afterward through `future.md`. |
## A. Schema And Typed Values
### A01. Terminate Pointer-Only Recursive Descriptor Traversal
Type: reported defect. Priority: medium. Source: correctness 1; testing 1.
**Problem and evidence.** In [schema.go](../../internal/engine/schema.go), a legal
type such as `type Loop *Loop` produces a pointer descriptor whose element is
itself. [decode.go](../../internal/engine/decode.go) unwraps that descriptor
without consuming a non-null input node; `resolveSchemaPath` likewise assumes
pointer traversal eventually reaches another kind. A public load with a `Loop`
field and `value: {}` overflowed the stack in a process with a small stack limit.
`value: null` succeeded, but resolving a normalization path for that field hung
until the diagnostic process was stopped. The [schema
contract](../config.md#application-schema-binding) permits recursive types with
acyclic values; the inline-field restriction is not a blanket pointer restriction.
**Recommendation and acceptance.** Detect repeated descriptors while unwrapping
pointer-only chains in decoding and schema-path resolution, and review analogous
helpers. Incompatible non-null input must fail safely; path traversal must
terminate and reject unaddressable descendants. Preserve supported nil/null
values and ordinary recursive structs. Do not add a blanket schema rejection or
arbitrary depth cap. Add bounded regression coverage for both decoding and helper
path resolution, keeping fatal/hanging probes out of the default test process.
### A02. Make Failed Dynamic Descriptor Construction Transactional
Type: reported defect. Priority: medium. Source: correctness 2; testing 1.
**Problem and evidence.** `dynamicDescriptor` in
[value_operation.go](../../internal/engine/value_operation.go) passes the load's
descriptor cache to `schemaBuilder`. Failed construction leaves cached container
placeholders. For a dynamic struct containing an unsupported channel field, two
`Normalize` calls in one resolver, first with a typed nil pointer and then a
non-nil pointer to that type, reproduced a nil-pointer panic on the second call.
The first call reported a diagnostic but left a reusable partial descriptor.
The application callback itself did not panic.
**Recommendation and acceptance.** Stage new descriptors and commit only on
successful construction, or roll back all additions from a failed build. Include
recursive references among newly built descriptors; clearing only the immediate
failed container or adding a projection nil guard is insufficient. Preserve
previously valid descriptors, load-local ownership, and safe path-specific
diagnostics under [typed projection](../config.md#typed-projection-and-ownership)
and [resolution](../config.md#recorded-application-resolution). Test repeated
operations after a failed build in the same load. Coordinate A05 only after this
failure-state boundary is sound.
### A03. Distinguish Acyclic Slice Views In Cycle Detection
Type: reported defect. Priority: medium. Source: correctness 3; testing 1.
**Problem and evidence.** `valueVisit` / `visitFor` in
[value_operation.go](../../internal/engine/value_operation.go) identify slices by
type, kind, and starting pointer, omitting length. The finite value built by
`shared := []any{"leaf", nil}; shared[1] = shared[:1]` was rejected through mapping
sources, typed defaults, and CLI overrides, while an equivalent independently
allocated value succeeded. The nested view contains only the leaf, not its
enclosing view. The same helper serves projection/cloning and
[value.go](../../internal/engine/value.go).
**Recommendation and acceptance.** Include visible slice length in active-visit
identity, or use an equivalent distinction. Preserve rejection of actual self
and mutual cycles, nil/empty behavior, and detached output; preserving alias
identity is not required. Regressions should cover the shared source, override,
projection, and cloning paths under the existing supported-acyclic-value contract,
alongside true-cycle rejection.
### A04. Enforce Existing Node Budgets Before Container Reservations
Type: reported defect. Priority: medium. Source: resources 1; testing 1.
**Problem and evidence.** `projectSequence` and `projectMap` in
[projection.go](../../internal/engine/projection.go) reserve space for the full
container, and maps collect all keys, before child charging in
[value_operation.go](../../internal/engine/value_operation.go). Defaults containing
a maximum-length slice of zero-sized structs caused a `makeslice: cap out of range`
panic instead of `schema.projection_limit`; that diagnostic did not allocate a
huge input backing array. Less extreme cases can allocate excessive temporary
memory before rejection. Application resolution can reach these paths too.
**Recommendation and acceptance.** Check the known minimum node cost against the
remaining existing allowance with overflow-safe arithmetic, or bound initial
capacity and grow as charging succeeds. Review map reservation/key collection as
well as sequences. Preserve node/depth semantics, inline handling, and useful
diagnostic paths. Use bounded regressions, including small controlled allowances,
without enormous allocations. This enforces an existing budget, not a new length
cap or universal heap-size guarantee.
### A05. Reuse Successful Dynamic-Type Eligibility Checks Locally
Type: optimization candidate. Priority: low. Source: efficiency 6.
**Problem and evidence.** Each dynamic value in projection/cloning invokes
`descriptorContainsSecret` with a new visited map, even when its descriptor is
cached. N values of one concrete type with descriptor-graph size S therefore cost
O(N × S) eligibility work. N interface values containing nil pointers to one wide
supported struct can produce only N null values while repeating the full type
walk. This is a source-derived bound, not measured runtime.
**Recommendation and acceptance.** After A02, consider remembering successfully
approved concrete descriptors within `valueOperation`, reducing repeated-type
eligibility work to O(S + N). Cache only completed successful eligibility checks,
not traversal-local visits, partial descriptors, or path-bearing failures.
Preserve rejection of dynamic secret-reference types, including nil pointers and
recursive descriptor cases. This does not select cross-load schema caching or a
clone traversal redesign.
## B. Callback And Filesystem Lifetime
### B01. Deactivate Callback Contexts During Panic Unwinding
Type: reported defect. Priority: low. Sources: correctness 4, concurrency 1;
testing 1; corroborated by error-handling and security reviews.
**Problem and evidence.** `runResolution` in
[resolution.go](../../internal/engine/resolution.go) and `runValidation` /
`reportVersion` in [validation.go](../../internal/engine/validation.go) deactivate
contexts only after normal callback return. Public probes retained the context,
panicked, recovered outside `Load`, and then successfully queried resolution or
wrote validation/version diagnostics. The concurrency audit reproduced this
with race instrumentation without reporting a data race. The defect violates
callback lifetime, not a newly supported concurrent-use scenario.
**Recommendation and acceptance.** Defer deactivation within each callback
invocation, closing immediately at normal return and on unwinding, before later
projection or metadata work. Preserve the original panic and prohibit retained
context use through the established closed-context behavior. Test all three
callback kinds with external recovery. No recovery service, lock, atomic flag,
or broader callback thread-safety guarantee is needed. B02 separately owns root
handle cleanup.
### B02. Close The Owned Import Root When A Version Callback Panics
Type: reported defect. Priority: medium. Source: resources 3; testing 1.
**Problem and evidence.** Host import acquisition in
[import_acquisition.go](../../internal/engine/import_acquisition.go) opens an
`os.Root` through [filesystem_access.go](../../internal/engine/filesystem_access.go)
but relies on explicit ordinary-path closes. An imported version-check panic
bypasses them. A disposable Linux probe disabled GC, recovered outside `Load`,
and observed one remaining root descriptor through `/proc/self/fd`; the normal
control returned to zero. The imported file's own deferred close still ran.
**Recommendation and acceptance.** Install deferred fallback cleanup at the
owning boundary, preserving explicit normal close timing and diagnostics, as
selected-overlay acquisition already does. Avoid double-close confusion, leave
borrowed filesystems untouched, and propagate the original panic. A regression
must observe the owned host root, not merely a native file-close counter, and
document platform prerequisites. Context deactivation in B01 is a separate
requirement. This restores load-scope cleanup without adding panic recovery.
## C. Import Selection And Link Resolution
### C01. Preserve Directory Failure Phase And Strengthen Its Tests
Type: reported defect with test improvements. Priority: medium.
Sources: error-handling 1, testing 3.
**Problem and evidence.** `filesystemAccess.readDirectory` in
[filesystem_access.go](../../internal/engine/filesystem_access.go) returns open,
read, and close errors through one unqualified error channel.
[import_selection.go](../../internal/engine/import_selection.go) and
[glob.go](../../internal/engine/glob.go) treat any `fs.ErrNotExist` as absence.
A borrowed filesystem probe selected a failing directory plus a good directory,
using both `dir` and `glob` with `required: true`. Injecting `fs.ErrNotExist`
alongside entries from `ReadDir`, or from `Close`, let loads succeed with the bad
directory's contribution omitted. Permission-classified controls failed. All
eight selector/phase/error cases ran. This violates the existing distinction
between optional absence and failed acquisition.
**Recommendation and acceptance.** Preserve missing-on-open versus post-open
failure, for example with a small private failure record analogous to regular
file acquisition. Keep genuine missing-target rules, entry budgets, first-error
precedence, borrowed filesystem ownership, and safe existing traversal diagnostics.
Review both batched `fs.ReadDirFile` and standard fallback paths. A successful
second directory must not hide enumeration/close failure; raw backing error text
must not leak. No retries or filesystem transaction are proposed.
**Test improvements.** The shared fixture in
[native_filesystem_test.go](../../internal/engine/native_filesystem_test.go) and
[resource_context_test.go](../../internal/engine/resource_context_test.go) currently
checks aggregate closes and often only non-nil errors. One leaked handle and
one double-close can cancel out. Its canary check examines the terse error
summary, not structured diagnostics. Track per-handle closure, assert nil result,
diagnostic code and source, and inspect diagnostic messages/rendering for canaries.
Add entries/data-plus-error and close-phase cases through narrow failure seams.
Preserve intentionally ignored read-only regular-file close errors; this does
not impose a universal close-error policy.
### C02. Avoid Reexpanding Completed Finite Symlink Dependencies
Type: reported availability defect. Priority: medium. Source: security 1;
testing 1.
**Problem and evidence.** Host/native resolvers in
[filesystem_links.go](../../internal/engine/filesystem_links.go) track active links
but reuse no completed resolution. A finite contained graph `s00 -> .`,
`s01 -> s00/s00`, `s02 -> s01/s01`, and so on repeats completed dependencies.
Its work follows T(N) = 2T(N-1) + O(1), despite linear graph size. Directory
selection resolves discovered links before deciding on recursive descent, so
`recursive: false` does not avoid it. Existing source/entry/node/import-depth
budgets do not account for the repeated expansion.
A real-link fixture exposed through a native `fs.ReadLinkFS` wrapper recorded
32, 128, 512, 2,048, and 8,192 `Lstat` calls for 3, 5, 7, 9, and 11 entries;
corresponding `ReadLink` counts were 11, 57, 247, 1,013, and 4,083. Thirteen
entries reached a diagnostic ceiling of 12,000 `Lstat` calls. With following
disabled, that fixture used 17 `Lstat` calls and no `ReadLink`. The host resolver's
matching logic was reviewed, not separately operation-count tested.
**Recommendation and acceptance.** Reuse fully resolved targets within the
resolution operation, keyed for the rooted namespace and full link path, or use
equivalent dependency evaluation. Retain active-path cycle detection, containment,
finite reuse, relative/absolute host semantics, and the stable-input assumption.
A global visited-means-cycle rule would incorrectly reject valid inputs. Cover
both resolvers and directory/glob entry points with small fixtures and bounded
operation observation, not timing thresholds or exhaustion tests. No arbitrary
link cap, public cache, or default-follow change is selected.
The availability risk requires influence over imported filesystem data; it is
not an anonymous remote vulnerability by itself. No concurrent mutation or
outside-root target is needed, and accidental graphs can cause the same work.
### C03. Separate Glob Entry Preparation From Traversal
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
belong to [future work](future.md#1-separate-glob-entry-preparation-from-traversal),
for consideration after this roadmap's selected work is complete.
## D. Retained History And Unused Evidence
### D01. Account For The Complete Published History Graph
Type: reported defect. Priority: medium. Source: resources 2; testing 1.
**Problem and evidence.** The checkpoint in [load.go](../../internal/engine/load.go)
and [history.go](../../internal/engine/history.go) counts a different root set
from [load_result.go](../../internal/engine/load_result.go) publication. Separately
cloned post-CLI input, `structuralInput`, `preCLI`, and later inheritance copies
are not all represented. A read-only diagnostic of a small successful load with
a no-op resolver counted 14 distinct nodes before including the two published
replay roots and 22 with them. Source inspection found that the checkpoint uses
a different, same-sized decoded-final instance and also counts 14 in this case.
Generalized to N scalar items, the reported counts were 3N + 8 versus 5N + 12;
800,000 items would pass the checkpoint yet exceed the existing 4,000,000-node
history allowance. That large case was derived, not allocated or executed.
**Recommendation and acceptance.** Use pointer-aware accounting for every document
root actually retained at publication, including replay and copied inheritance
history, counting shared nodes once. Where practical, reserve/charge copies before
allocation. Coordinate removal of unused trees under D03 and recalculate the
retained set afterward; that removal alone does not establish complete accounting.
Reuse already-owned immutable snapshots only where equivalence and ownership
are demonstrated. This need not redesign public metadata copying.
Use a small controlled allowance to test publication integration and the existing
`explain.limit_exceeded` outcome, as well as count-once sharing. Helper-only tests
cannot detect roots omitted by callers. Preserve [historical
inspection](../config.md#historical-value-explanation), detached ownership, and
the existing quota; no smaller limit or universal heap-size bound is proposed.
### D02. Remove The Unconsumed Override-Result Pre-CLI Copy
Type: cleanup. Priority: medium. Source: deadcode 1.
**Problem and evidence.** `decodedOverrideResult.preCLI` in
[override_decode.go](../../internal/engine/override_decode.go) clones the entire
decoded final tree even without CLI assignments. Its readers are private tests;
production independently clones `before.final` into the active context in
[override_context.go](../../internal/engine/override_context.go).
**Recommendation and acceptance.** Remove that result member, its clone, and
assertions solely about it. Preserve the independently owned pre-CLI context
that actually feeds resolution, publication, and explanation. Retain behavioral
CLI/history/ownership coverage. The change removes a redundant traversal, not
the active replay snapshot or its detachment boundary.
### D03. Remove The Unqueried Published Structural-Input Copy
Type: cleanup. Priority: medium. Source: deadcode 2.
**Problem and evidence.** `Inspection.structuralInput` in
[load.go](../../internal/engine/load.go) is cloned and indexed at publication,
but has no production reader. [explain.go](../../internal/engine/explain.go)
uses source/inheritance replay, `preCLI`, and `input`. The immutable lookup is
addressed by supplied node pointers; indexing this otherwise unused root does
not make it useful and keeps its mappings alive.
**Recommendation and acceptance.** Remove the inspection member, final clone,
and its `retainedTrees` inclusion. Keep earlier `resolved.structuralInput` uses
for path finalization and sensitivity. Check explanation, historical paths,
redaction, and ownership through active outputs. Coordinate D01 for all remaining
retained roots; do not infer that structural evidence is globally unused.
### D04. Stop Constructing Unconsumed Combination Evidence
Type: cleanup. Priority: medium. Source: deadcode 5.
**Problem and evidence.** `sequenceCombinationResult.discardedLower` in
[sequence_combine.go](../../internal/engine/sequence_combine.go) allocates a list
of all replaced lower indexes with no production reader. Three tests inspect it.
`inheritanceReplacement.path`, `previous`, and `effective` in
[inheritance.go](../../internal/engine/inheritance.go) are also unread, but their
writers perform path/origin copies and displaced-subtree walks. Active inheritance
history uses operations and projected changes elsewhere. Replacement `before`
and `after` trees still feed node accounting.
**Recommendation and acceptance.** Remove only unread members and work solely
feeding them. Keep replacement trees, item projections, replacement mappings,
`appendedHigher`, active provenance/history, and node accounting. Keep
`sequenceIndexes` for append and `displacedOrigins` for overlay callers. Replace
private unused-evidence assertions with existing behavior-level replacement,
removed-descendant, and history protection where necessary. Coordinate D01 and
E03 without deleting active evidence merely because another representation exists.
## E. Collection Processing And Efficiency
All complexity estimates here describe identified internal work with bounded-
depth examples and expected hash lookup costs. Actual path processing, copied
payload, and expanded output remain costs; no end-to-end complexity or timing
guarantee is proposed.
### E01. Avoid Scanning Every Policy For Each Replacement
Type: optimization candidate. Priority: medium. Source: efficiency 1.
**Problem and evidence.** `retireCollectionPoliciesAtOrBelow` in
[collection_policy_state.go](../../internal/engine/collection_policy_state.go)
scans all P policies for each of Q ordinary replacements in
[overlay.go](../../internal/engine/overlay.go), even if the changed scalars are
unrelated to every collection. P = Q = N gives O(N²) visits for linear-sized
input and output within one tier.
**Recommendation and acceptance.** Consider an operation-local semantic-path
index with descendant queries, maintained as policies change, or batch retirement
where timing remains equivalent. The disjoint case can approach O(P + Q), with
additional work charged to affected entries/path lengths. Preserve explicit-mode
persistence, record ownership, and the moment old identity retires; delayed
retirement must not clear newly established higher-tier policy. Evaluate existing
inheritance indexing without requiring identical interfaces. Retain a distinct
disjoint-replacement case even if implementation is coordinated with E02/E03.
### E02. Avoid Recopying Unrelated Policy Across Small Tiers
Type: optimization candidate. Priority: medium. Source: efficiency 2.
**Problem and evidence.** `lowerCollectionPoliciesForClaims` in
[collection_retirement.go](../../internal/engine/collection_retirement.go) clones
and scans lower policy, and `resolveStrictCollectionPolicies` in
[compose.go](../../internal/engine/compose.go) clones it again. Overlay preparation
repeats full-state work and checks retained destinations. P initial policies
followed by T scalar-only tiers cost O(T × P) work/allocation although only O(P + T)
material exists. Copies include record order and nested record policy; these
are transient private copies, not independently published snapshots.
**Recommendation and acceptance.** Separate read-only lower-policy lookup from
owned current-tier changes, localize work to incoming claims/declarations, and
apply changes to the assembly owner. Keep copies at actual mutable ownership
boundaries. Removing the immediate duplicate copy helps but does not alone fix
O(T × P) growth. Preserve pending declarations, replacement eligibility, and
independent nested record policies. This case remains distinct from E01 and the
deferred public metadata-copy experiment.
### E03. Clarify Policy Preparation And Merge Evidence Application
Type: selected refactor. Priority: medium. Source: complexity 3.
**Problem and evidence.** [overlay.go](../../internal/engine/overlay.go) combines
claim/declaration preparation, operation resolution, callbacks, diagnostics,
origins/history, retirement, record policy rebuilding, and declaration-only
finalization. Overlay and [inheritance](../../internal/engine/inheritance_collection.go)
separately translate sequence results into history descriptors and reconstruct
record order. `combineSequence` already owns shared value combination.
**Recommendation and acceptance.** Consider explicit preparation, application,
and finalization operations with a small result/owner for evidence and diagnostics.
Share straightforward history-descriptor construction; assess projected-record
selection sharing while keeping precedence and inheritance policy ownership
explicit. Do not unify distinct origin or mode-lifetime rules behind a generic
strategy framework. Coordinate E01/E02 so extraction does not preserve avoidable
full-table work. Preserve pending declarations, replacement ordering, retained
versus incoming record owners, and stable history. D04 can remove unused evidence
without requiring this broader refactor.
### E04. Index Tier-History Projection Lookups
Type: optimization candidate. Priority: medium. Source: efficiency 3.
**Problem and evidence.** `projectTierHistoryApplications` in
[sequence_history_projection.go](../../internal/engine/sequence_history_projection.go)
calls unindexed `documentAtPath` for each mapped sequence. N sibling collections
selected by one wildcard identity registration incur O(N²) mapping comparisons
while projecting linear-sized source/history material.
**Recommendation and acceptance.** Lazily prepare one read-only `documentLookup`
per original application value with mapped contributions, or collect needed nodes
in one traversal. With V nodes, S contributions, depth D, and I projected items,
that portion can be O(V + S × D + I). Preserve authored coordinates, sparse item
projections, replacement evidence, and independent output storage. This is load-
time coordinate projection, not branch-local explanation replay.
### E05. Restore Compound Identity Without Repeated Mapping Scans
Type: optimization candidate. Priority: medium. Source: efficiency 4.
**Problem and evidence.** `preserveInheritanceIdentityFields` in
[inheritance_scope.go](../../internal/engine/inheritance_scope.go) searches child
and merged mappings for each identity field. K fields and C/M child/merged
entries cost O(K × (C + M)) comparisons plus required clones. A wide compound
identity can make this quadratic without expanding record output.
**Recommendation and acceptance.** Index merged positions and walk child entries
against an identity-field set, or use equivalent local mapping indexes, for
O(K + C + M) lookup work plus copies. Preserve child-owned identity values and
locations, mapping order, and detached nodes. Do not change identity equality or
parent selection. Removing the restoration step instead requires a separate
equivalence review; apparent duplication alone is insufficient.
## F. Remaining Maintenance And Test Improvements
### F01. Remove The Test-Only Eager Acquisition Mode
Type: cleanup. Priority: medium. Sources: complexity 1, deadcode 8.
**Problem and evidence.** Both production constructors of the request declared in
[acquisition.go](../../internal/engine/acquisition.go) set
`inputAcquisitionRequest.deferAssembly` to true. Only private
test helpers select the eager composition branch in
[import_acquisition.go](../../internal/engine/import_acquisition.go). Acquisition
maintains two return protocols and tests can bypass the production phase handoff.
**Recommendation and acceptance.** Return retained scopes consistently and remove
the unused production mode. Tests needing composition should invoke the active
composition phase through a small test-only helper; acquisition-only tests should
remain acquisition-only. Preserve closure, pending-item reservation, accounting,
diagnostics, and meaningful composition coverage. This does not retire public
loading workflows or imply that every nil-state/directive context is unused.
### F02. Clarify Active Acquisition And Coordinator Handoffs
Type: selected refactor. Priority: medium. Source: complexity 2.
**Problem and evidence.** `acquireInput` in
[acquisition.go](../../internal/engine/acquisition.go) combines discovery, parsing,
directive/version branches, placement/accounting, dispatch, and passive contexts.
Version branches repeat source-ID/failure/result handling, and layered processing
extracts and restores directives. [load.go](../../internal/engine/load.go) contains
a substantial ordinary-load context fallback; [load_request.go](../../internal/engine/load_request.go)
repeats tier declaration/application/projection handoffs. Parsed, local, composed,
inherited, and version-path representations are difficult to follow together.
**Recommendation and acceptance.** Consider cohesive parsed-input directive/version
processing and standalone placement/registration operations, plus explicit context,
structural-expansion, and repeated tier handoffs. Reuse existing state/results;
helpers that merely share the same mutable surrounding locals do not resolve
ownership. Preserve visible phase order, ordinary/layered overlay differences,
default/enabled version timing, warnings, exactly-once callbacks, and load-wide
versus tier-local state. F01 removes a different, inactive protocol; neither task
requires a new context hierarchy or public request model.
### F03. Retire The Parallel Test-Only Import Directive Parser
Type: cleanup. Priority: low. Source: deadcode 7.
**Problem and evidence.** `parseImportSpecificationDirective` in
[import_spec.go](../../internal/engine/import_spec.go) is called only by the
`parseImportSpecsYAML` test fixture. It duplicates mapping/field dispatch already
owned by `parseRootDirectiveBlock` in [directives.go](../../internal/engine/directives.go).
**Recommendation and acceptance.** Use the active parser with the appropriate
context, or parse the declaration list directly when that is the tested boundary,
then remove the obsolete implementation. Preserve malformed-declaration and
selector coverage. Keep the active imports-only context used in layered version
processing; thin test helpers delegating to production are not inherently dead.
### F04. Remove The Disconnected Root Record-Clone Family
Type: cleanup. Priority: low. Source: deadcode 3; related testing 6.
**Problem and evidence.** The private family in root [records.go](../../records.go)
has no production facade entry: `cloneLocation`, `cloneLocations`, `cloneSourceInfo`,
`cloneImportSelections`, `cloneSourceVersionCheck`, `cloneSourceInfos`, `cloneOrigin`,
`cloneFieldInfo`, `cloneChange`, `cloneDiagnostic`, and `cloneDiagnostics`.
Helpers refer to each other; only tests call `cloneSourceInfo`. Active root-owned
records come through [conversion.go](../../conversion.go).
**Recommendation and acceptance.** Delete the disconnected family and tests solely
protecting it. Keep conversion assertions and useful returned-record ownership
coverage through repeated public queries and mutation of mutable nested results.
Replacing an entire `Destination` field on a copied struct does not demonstrate
nested detachment. Keep active engine clones and `Path.clone`; do not expose
opaque path internals or invent a stronger copy guarantee. Coordinate F12.
### F05. Remove Uncalled Engine Helpers
Type: cleanup. Priority: low. Source: deadcode 4; placement subset also complexity 5.
The type-aware audit found no callers or function-value uses in the inspected
production/test build configurations for the following helpers:
| Candidate | Active counterpart to preserve |
| --- | --- |
| `mergeDisjointMappings` in [compose.go](../../internal/engine/compose.go) | `mergeDisjointMappingsAtPath` and shared recursive merging; the old helper references only itself. |
| `mergeHistoryDocument` in [history.go](../../internal/engine/history.go) | `mergeHistoryDocumentAtPath` with sequence evidence. |
| `inheritanceDirectiveLocation` in [inheritance.go](../../internal/engine/inheritance.go) | Preparation's directive entry/location handling. |
| `addSecretEvidencePath` in [secret.go](../../internal/engine/secret.go) | Projected version sensitivity evidence and the active path accumulator. |
| `PlacementModeFromFacade`, `PlacementModeForFacade` in [destination.go](../../internal/engine/destination.go) | Input/options conversion and `SourceInfoPlacementModeForFacade`. |
**Recommendation and acceptance.** Remove these definitions and imports/comments
solely supporting them after confirming references at implementation time. Preserve
active counterparts and public root APIs. The placement pair can be removed
without selecting the broader transport work in F07.
### F06. Trim Unread Private Result And Transport Members
Type: cleanup. Priority: low. Source: deadcode 6.
The audit identified the following populated members without production readers.
Some private tests inspect them. Remove only these slots and computations solely
feeding them, preserving validation and active outputs:
| Owner and candidate members | Boundary to preserve |
| --- | --- |
| `collectionPolicyResolution.declarations`, `declarationsPresent`, `identityDeclarationPresent`, `applicationIdentityFields` in [collection_policy.go](../../internal/engine/collection_policy.go) | Local declaration normalization, `modeDeclarationPresent`, and `applicationIdentityPresent`. |
| `inputPresence.null` in [decode.go](../../internal/engine/decode.go) | Presence/location and effective-node null reporting. |
| `rootDirectiveExtraction.importsPresent`, `sequencesPresent` in [directives.go](../../internal/engine/directives.go) | Parsed declarations, diagnostics, and directive validation. |
| `importSelection.outcome` in [import_selection.go](../../internal/engine/import_selection.go) and glob selection | Candidate/admission behavior and the distinct, active public `importSelectionEvidence.outcome`. |
| `importDeclarationSpec.excludeSet` in [import_spec.go](../../internal/engine/import_spec.go) | Active `exclude` policy. |
| `schemaPath.field`, `dynamic` in [schema.go](../../internal/engine/schema.go) | Descriptor and success result used by path consumers; coordinate A01. |
| `selectedOverlayRequest.filesystem` in [selected_overlay.go](../../internal/engine/selected_overlay.go) | Explicit filesystem-access argument and borrowed ownership. |
| `sequenceDeclarationSpec.destinationLocation` in [sequence_declaration.go](../../internal/engine/sequence_declaration.go) | Other active declaration/field diagnostic locations. |
| `sourceVersionEvidence.absence`, `checked`; `sourceVersionAncestor.kind` in [source_version.go](../../internal/engine/source_version.go) | Actual checks, version/presence/location evidence, ancestor paths, and sensitivity. |
| `postDecodeOverrideContext.sources` in [override_context.go](../../internal/engine/override_context.go) | Local source list used to build callback resolution input. |
**Acceptance.** Preserve public presence, null, selection, version, diagnostic,
and ownership behavior. Rework assertions on inactive annotations only where
they protect a meaningful supported outcome. A lack of selector reads alone is
not a general removal rule: map keys, embedded records, and interfaces require
separate analysis.
### F07. Simplify Redundant Facade-To-Engine Transport
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
belong to [future work](future.md#2-simplify-redundant-facade-to-engine-transport),
for consideration after this roadmap's selected work is complete. F05's bounded
removal of unused placement bridges remains selected.
### F08. Index Secret-Failure Location Queries Lazily
Type: optimization candidate. Priority: low. Source: efficiency 5.
**Problem and evidence.** `secretDiagnostic` in
[secret_reference.go](../../internal/engine/secret_reference.go) calls unindexed
nearest-location traversal for each failed reference. N missing references in a
wide mapping incur O(N²) comparisons, even when they use one cached environment
name. Normal deterministic traversal/sorting does not require these scans.
**Recommendation and acceptance.** Consider a lazy read-only `documentLookup`
shared during secret resolution, or carry relevant location state through the
walk. With V nodes, F failures, and depth D, attribution can be O(V + F × D),
apart from sorting. Keep the successful path free of an unnecessary eager index.
Preserve all diagnostics, ancestor fallback, source coordinates, and safe messages;
failing fast would change behavior rather than optimize it.
### F09. Cover Public Historical-Path Enumeration
Type: test improvement. Priority: medium. Source: testing 2; related testing 6.
**Problem and evidence.** Root `Metadata.HistoricalPaths` in
[metadata.go](../../metadata.go) had no executed statements in the aggregate
profile. Engine tests cover it thoroughly, but not root path conversion. An empty
or incorrectly converted public result could leave engine tests green.
**Recommendation and acceptance.** Add one external-package test enumerating
effective and removed paths, using returned paths with `Lookup` and `History`,
including a numeric-key/sequence shape change. Cover sorted unique pointer
spelling, zero metadata, and replacement of a returned slice element without
affecting later queries. Keep detailed path mutation/shape tests in the engine.
This can replace F12's private segment-representation assertion without copying
the complete internal matrix or exposing private segments.
### F10. Strengthen Fuzz Detachment Assertions
Type: test improvement. Priority: low. Source: testing 4.
**Problem and evidence.** `FuzzCompositionAndOverlay` in
[composition_fuzz_test.go](../../internal/engine/composition_fuzz_test.go) appends
to the result root then compares inputs. A shallow root copy sharing existing
descendants can pass because append changes neither the input's slice header nor
elements within its old length. Existing deterministic merge tests already
exercise meaningful nested mutation.
**Recommendation and acceptance.** Mutate an existing result descendant or
in-length element within the bounded fuzz target, then compare both inputs.
Handle empty containers separately and retain input immutability/commutativity
properties. Improve the existing assertion rather than adding another ownership
matrix or claiming all ownership coverage is absent.
### F11. Replace Exact Stat-Call Choreography With Observable Ordering
Type: test cleanup. Priority: low. Source: testing 5.
**Problem and evidence.** `TestSelectNamedImportsChecksNormalizedPathsLexically`
in [import_selection_test.go](../../internal/engine/import_selection_test.go)
requires `a.yaml,a.yaml,z.yaml,z.yaml` stat calls but checks candidate count only.
Lexical handling matters; exactly two stats per file is not an established contract.
**Recommendation and acceptance.** Assert ordered candidate paths and, where
needed, the first diagnostic site using failing targets. Consolidate with existing
selection tests and delete the standalone case if its protection is subsumed.
Keep meaningful observations such as not opening excluded contents. Do not turn
incidental helper call counts into an efficiency or behavior guarantee.
### F12. Consolidate Facade Scaffolding Around Public Behavior
Type: test cleanup. Priority: low. Source: testing 6; related deadcode 3.
**Evidence and recommendations.**
- Remove the duplicate six-type ownership loop in
[facade_compatibility_test.go](../../facade_compatibility_test.go), retaining the
comprehensive owner in [facade_candidate_test.go](../../facade_candidate_test.go),
compile-time interfaces, public construction, and distinct lifecycle checks.
A literal assignment equality assertion on a new `Result.Config` adds no
protection beyond compilation and ordinary Go assignment.
- Replace the former `TestWrappedPathsRetainEngineSegmentIntent` internal
representation check with public contextual mapping/index behavior under F09.
Keep engine segment-intent coverage; public source-form behavior is consolidated
in [facade_equivalence_test.go](../../facade_equivalence_test.go).
- Move `TestWrappedSourcesPreserveEngineOwnership` from direct `engine.Load`
through `source.toEngine()` to public `Load` and root error assertions.
Consolidate reader cases with [facade_equivalence_test.go](../../facade_equivalence_test.go)
while preserving explicit copied-reader and byte-source mutation coverage.
- F04 owns removal of the disconnected clone family and the ineffective whole-
destination assignment assertion in [records_test.go](../../records_test.go).
Keep useful conversion and returned-record ownership protection there or in
existing public source-snapshot tests.
**Acceptance.** Remove scaffolding only after preserving its distinct behavioral
risks. Root integration tests still protect conversion, error typing, callbacks,
source identity, disclosure, and ownership; engine coverage does not substitute
for them. Static public/dependency-boundary tests remain useful.
## Boundaries To Preserve
The audits found the architecture coherent and did not establish a need for new
public features, a replacement loading API, package proliferation, or a general
visitor/strategy framework. Root-owned conversions, synchronous load-local state,
shared dependency scheduling, indexed combination, separated inheritance phases,
and passive resource contexts remain useful owners. The clone traversal was
already reviewed and retained at `34cbc0d:docs/roadmap/consolidation.md`; a high
complexity estimate does not reopen it.
No additional supported-use data race, deadlock, background-work leak, credential
disclosure, command injection, authority bypass, or host containment escape was
established. These are limited audit observations, not absence guarantees.
Caller-owned mutable objects, blocking operations, safe callback messages, and
borrowed filesystem behavior retain their documented responsibilities. No new
locking, cancellation, retry, authentication, permission, or environment-variable
restriction follows from these findings.
Preserve raw-error suppression, structured safe diagnostics, original application
panic propagation, intentional bootstrap partial writes, allowed partial writer
output, and documented read-only regular-file close handling. Regression tests
must enforce existing contracts rather than broaden them. Optional absence must
remain distinct from acquisition failure, and genuine finite reuse from cycles.
Public APIs are not dead merely because repository examples omit them. Interface
dispatch, reflection, build constraints, identity tokens, map-key equality, and
uninstantiated generics require more than caller counts. In particular,
`decodeInlineMapEntry` and import-cycle diagnostics remain active.
`Inspection.resolution` retains recorded snapshots under the existing resolution
contract even though replay uses a coalesced view; it is not a cleanup candidate.
Selected stable Kahn ordering, deterministic sorting, exact numeric identity,
and the expressly selected registration-overlap check are not reconsidered here.
Required detached copies and genuinely expanding output remain legitimate costs.
The broader replay, metadata-handoff, cross-load schema-cache, and contributor-
trace experiments stay in [future.md](future.md#5-performance-experiments).
Local fixes and removal of proven unused work do not select those experiments.
## Validation Approach For Selected Work
Use the existing [development guide](../development.md#baseline-validation) and
[testing policy](../policy/testing.md). Add meaningful regressions with the
selected fixes, at the narrowest stable boundary that detects the reported
failure. Extend existing owners rather than duplicating internal matrices at
every layer. Assert nil results and safe structured failures where relevant,
and verify retained state/handles independently when they have distinct owners.
Keep termination, allocation, and link-expansion probes bounded. Use small
controlled budgets, disposable processes where necessary, and filesystem
observation where it detects the specific defect. No wall-clock thresholds,
coverage percentages, complexity-score limits, exact incidental syscall traces,
new resource caps, or extra release gates are selected. Algorithmic evidence is
not a measured speedup. Performance experiments need separate scope and suitable
measurement conditions; no benchmarks were run for this consolidation.
Once selected work changes behavior or structure, update its canonical contract
or internal owner as appropriate. This review document remains the finding/status
owner until an implementation plan is prepared; completed records can retire
to Git history under the normal documentation lifecycle.
## Original-Finding Map
Original files are retrieved from `ecd5d5d:docs/roadmap/<filename>`. Every numbered
finding is mapped below; repeated citations are corroboration or shared coverage,
not extra fixes.
| Original finding | Consolidated owner |
| --- | --- |
| correctness 1 | [A01](#a01-terminate-pointer-only-recursive-descriptor-traversal) |
| correctness 2 | [A02](#a02-make-failed-dynamic-descriptor-construction-transactional) |
| correctness 3 | [A03](#a03-distinguish-acyclic-slice-views-in-cycle-detection) |
| correctness 4 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding) |
| efficiency 1 | [E01](#e01-avoid-scanning-every-policy-for-each-replacement) |
| efficiency 2 | [E02](#e02-avoid-recopying-unrelated-policy-across-small-tiers) |
| efficiency 3 | [E04](#e04-index-tier-history-projection-lookups) |
| efficiency 4 | [E05](#e05-restore-compound-identity-without-repeated-mapping-scans) |
| efficiency 5 | [F08](#f08-index-secret-failure-location-queries-lazily) |
| efficiency 6 | [A05](#a05-reuse-successful-dynamic-type-eligibility-checks-locally) |
| complexity 1 | [F01](#f01-remove-the-test-only-eager-acquisition-mode) |
| complexity 2 | [F02](#f02-clarify-active-acquisition-and-coordinator-handoffs) |
| complexity 3 | [E03](#e03-clarify-policy-preparation-and-merge-evidence-application) |
| complexity 4 | [C03](#c03-separate-glob-entry-preparation-from-traversal) |
| complexity 5 | [F07](#f07-simplify-redundant-facade-to-engine-transport), with bounded bridge removal in [F05](#f05-remove-uncalled-engine-helpers) |
| concurrency 1 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding), corroboration |
| resources 1 | [A04](#a04-enforce-existing-node-budgets-before-container-reservations) |
| resources 2 | [D01](#d01-account-for-the-complete-published-history-graph) |
| resources 3 | [B02](#b02-close-the-owned-import-root-when-a-version-callback-panics) |
| error-handling 1 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests) |
| security 1 | [C02](#c02-avoid-reexpanding-completed-finite-symlink-dependencies) |
| testing 1 | Regression acceptance under A01–A04, B01–B02, C02, and D01 |
| testing 2 | [F09](#f09-cover-public-historical-path-enumeration) |
| testing 3 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests), including shared fixture assertions |
| testing 4 | [F10](#f10-strengthen-fuzz-detachment-assertions) |
| testing 5 | [F11](#f11-replace-exact-stat-call-choreography-with-observable-ordering) |
| testing 6 | [F12](#f12-consolidate-facade-scaffolding-around-public-behavior), with F04 and F09 |
| deadcode 1 | [D02](#d02-remove-the-unconsumed-override-result-pre-cli-copy) |
| deadcode 2 | [D03](#d03-remove-the-unqueried-published-structural-input-copy) |
| deadcode 3 | [F04](#f04-remove-the-disconnected-root-record-clone-family) |
| deadcode 4 | [F05](#f05-remove-uncalled-engine-helpers) |
| deadcode 5 | [D04](#d04-stop-constructing-unconsumed-combination-evidence) |
| deadcode 6 | [F06](#f06-trim-unread-private-result-and-transport-members) |
| deadcode 7 | [F03](#f03-retire-the-parallel-test-only-import-directive-parser) |
| deadcode 8 | [F01](#f01-remove-the-test-only-eager-acquisition-mode), duplicate of complexity 1 |
## Settled Scope Decisions
### OQ-1. What Should The First Implementation Scope Include?
**Settled:** include all nine reported defects with their regression coverage,
the bounded cleanup, and the identified independent test improvements.
| Selected work | Finding IDs |
| --- | --- |
| Reported defects and accompanying regression coverage | A01–A04, B01–B02, C01–C02, D01 |
| Removal of unused copies, evidence, helpers, fields, and test-only production paths | D02–D04, F01, F03–F06 |
| Public coverage and stronger or less redundant test assertions | F09–F12 |
This selects 21 review entries. Reconfirm cleanup evidence on the implementation
baseline before removal, and preserve distinct behavioral protection when
replacing tests. Coordinate unused history copies with D01's retained-graph
accounting; verify callback-state closure and host-root closure independently.
Keep fixes and cleanup in focused, reviewable commits, even if delivered in one
sprint. Cleanup must not delay a consequential fix.
The combined scope restores existing contracts and removes demonstrated
maintenance burden while avoiding repeated work at the same ownership and test
boundaries. Delivering defects first and cleanup separately would reduce the
initial review surface, but would retain redundant work and revisit those
boundaries. The combined approach is selected.
Efficiency and broader structural choices are addressed separately below. This
decision does not select new public behavior or establish an implementation
sequence.
### OQ-2. Which Efficiency Candidates Should Accompany Remediation?
**Settled:** include the four localized efficiency improvements below. E01/E02
are addressed separately with collection-policy preparation and evidence
ownership (E03) under the settled OQ-3 decision.
| Selected improvement | Scope and dependency |
| --- | --- |
| E04: tier-history projection lookups | Reuse a local lookup index or equivalent traversal while preserving authored coordinates and projection evidence. |
| E05: compound identity restoration | Avoid repeated mapping scans while preserving child identity, mapping order, and detached nodes. |
| A05: dynamic-type eligibility reuse | Reuse only completed successful checks within the operation, after A02 repairs descriptor construction failure state. |
| F08: secret-failure location lookup | Include at lower priority; prepare lookup state lazily so successful loads avoid unnecessary indexing. |
Together with OQ-1, this selects 25 review entries. These local changes address
source-demonstrated repeated work without requiring public APIs, persistent
caches, or a new architectural model. Preserve their behavioral acceptance
cases and keep implementation proportionate. E01/E02 involve policy retirement
timing and mutable ownership, so their scope belongs with the E03 discussion.
Deferring all six until consumer profiling was considered. That would reduce
initial scope and avoid optimizing uncommon workloads, but would retain known
growth patterns with relatively direct remedies in the four selected cases.
The selected approach does not promise a measured startup improvement; practical
benefit depends on configuration shape and workload. Broader experiments in
[future.md](future.md#5-performance-experiments) remain deferred. C02's availability
fix and A04/D01's existing-budget enforcement remain selected independently.
### OQ-3. How Much Structural Refactoring Should Be Selected?
**Settled:** select E01, E02, E03, and F02. Defer C03 and F07 to the first two
entries of [future.md](future.md) for consideration after the rest of this roadmap
is complete. F01's removal of the test-only protocol remains selected under OQ-1.
| Selected work | Scope |
| --- | --- |
| E01/E02/E03: collection-policy retirement, copying, and evidence ownership | Address the related costs and preparation/application boundaries together, retaining distinct scaling and behavioral acceptance cases. |
| F02: active acquisition and coordinator handoffs | Extract cohesive repeated operations with clear inputs/results while preserving visible phase order and ordinary/layered differences. |
For collection policy, distinguish read-only existing state, policy affected by
incoming declarations or replacements, and the changes/evidence retained by the
assembly. Preserve retirement timing, newly established policy, explicit-mode
persistence, and nested record ownership. A smaller function or fewer copies
alone does not establish success. For acquisition, moving blocks into helpers
that continue sharing mutable surrounding state would not clarify the handoff;
keep the existing loading model and named responsibilities.
The selected combination offers a focused improvement in efficiency and
maintainability at active ownership boundaries. Including C03/F07 now would
broaden review for more modest readability benefits; neither is necessary for
the selected fixes. They remain plausible follow-up work, not rejected proposals.
Deferring all structural work would reduce immediate change scope but retain the
policy inefficiencies and difficult acquisition handoffs. No new public API,
generic framework, or configuration semantics are selected.
Together, OQ-1 through OQ-3 select 29 entries. All scope questions are settled;
the next step is to prepare the implementation plan with sequencing and focused
validation. No implementation has been performed by these planning decisions.

View File

@@ -6,14 +6,6 @@ plans, or release commitments. Adopting a candidate requires an explicit design
decision and a separate plan. Significant architectural exclusions and their rationale belong decision and a separate plan. Significant architectural exclusions and their rationale belong
in ADRs; this document does not maintain a list of rejected proposals. in ADRs; this document does not maintain a list of rejected proposals.
## 1. Separate glob entry preparation from traversal
Implemented in the [internal-boundary roadmap](internal-boundaries.md#glob-entry-preparation) (C03).
## 2. Simplify redundant facade-to-engine transport
Implemented in the [internal-boundary roadmap](internal-boundaries.md#facade-to-engine-transport) (F07).
## 3. Recursive body merging for identified records ## 3. Recursive body merging for identified records
Status: deferred. The implemented Status: deferred. The implemented
@@ -91,3 +83,22 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
across loads without introducing aliasing? across loads without introducing aliasing?
- What cache lifetime and concurrency design would make schema reuse worthwhile - What cache lifetime and concurrency design would make schema reuse worthwhile
without excessive retained memory? 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

@@ -1,296 +0,0 @@
# Import Discovery And Facade Simplification Implementation Plan
Status: complete.
Baseline: `bb5540b62e382106d59678e2edb7655acfd5c960`.
Audience: a Luna coding agent implementing the stages sequentially.
## Scope And Execution
Implement **C03** and **F07** from the
[internal-boundary roadmap](internal-boundaries.md). That document owns the target
state and preservation requirements; this plan owns concrete private choices,
steps, validation, and progress. The preceding 29-finding remediation sprint is
complete. Its implementation record remains at
`bb5540b:docs/roadmap/implementation.md`; this plan does not reopen it.
Read `AGENTS.md`, [agent policy](../policy/agents.md), the [development
guide](../development.md), and applicable architecture, testing, and documentation
policies. Use graph-first discovery and verify actual callers before deleting
helpers. Preserve unrelated work.
This is one end-to-end sprint. Intermediate stages may fail to compile, especially
the coordinated engine/facade cutover in Stages 2–3. Do not create adapters,
aliases, duplicate paths, or extra abstractions solely to make intermediate
stages green. Run focused checks once their affected surface is buildable; record
pending checks and finish them before final acceptance. Commit focused nonempty
units under repository policy and record stage status, actual validation, and
known commit identifiers here. Completion requires the integrated result, not
just checked-off stages.
### Boundaries
- Preserve public root-owned declarations and signatures, YAML syntax, loading
semantics, ordinary/layered differences, callback order, diagnostics, source
identity, inspection, confidentiality, and ownership.
- Preserve glob literal-versus-discovered rules and directory/glob differences.
Keep completed-link reuse, directory failure phases, containment, and existing
accounting. This is not a discovery algorithm or permission-policy change.
- Keep `resolvedImportPolicy`, registration compilation, normalized request
responsibilities, and conversions with real ownership or domain meaning.
Simplification is limited to defaults transport and placement construction.
- Add no dependencies, caller controls, caches, limits, background work, release
gates, or performance promises. Do not publish a release, update consumers, or
revise accepted ADR decisions.
### Selected Ownership Model
Use the existing engine `ImportDefaults` name and its exported `Required`,
`Follow`, `Hidden`, `Recursive`, and `Pattern` fields as the sole engine
input-defaults type. Root `ImportDefaults` stays separately defined. Root
conversion maps the fields without cloning them; the public loading wrappers
immediately enter engine preflight. `loadSettings` takes the per-load snapshot
before acquisition or callbacks, as it already does. The normalized request and
acquisition policy then borrow that load-owned read-only value.
Declaration resolution reads the owned defaults and retains its existing copies
of selected pattern/exclusion slices and destinations in `resolvedImportPolicy`.
This separates presence-aware load input from independently owned effective
declaration policy. No public copy-count or thread-safety guarantee follows.
For placement, expose the engine's existing enum as `PlacementMode uint8` with
`PlacementModeMapping` and `PlacementModeSequenceItem`, and expose placement
fields on engine input/options records for direct construction. These remain
internal-package implementation surfaces. Root types stay root-owned; direct
casts preserve unknown values for existing preflight validation.
### Validation Conventions
Run from the repository root with an installed compatible Go toolchain and
dependencies already available. Use `GOTOOLCHAIN=local`, `GOPROXY=off`, and
`GOTELEMETRY=off`. Keep disposable artifacts outside the repository and do not
change dependencies for tooling. Focused expressions identify behavior owners;
include a newly named regression explicitly if needed. Reuse unchanged successful
checks. No timing, allocation, exact syscall, or helper-shape assertions are
acceptance criteria.
## Stage 1 — Extract Patterned Glob Entry Preparation
Finding: **C03**. Owners: `internal/engine/glob.go`, existing glob selection,
link, and directory failure tests. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'` and `git diff --check`.
1. Add `prepareGlobEntry` beside the selector. Take the filesystem access,
base path, parent `selectedImportCandidate`, `fs.DirEntry`, pattern segment,
`last` flag, and `resolvedImportPolicy`. Return a candidate, a boolean meaning
selected versus ordinary skip, and a nullable `*Diagnostic`. Use the parent's
existing `site`, which is propagated from the selector's root candidate.
No candidate-set, accounting, or traversal-state mutation belongs here.
2. Move only the patterned branch's per-entry body into this helper. Preserve
its order: `path.Match`; match/hidden filtering; lexical/access path creation
and containment; `importEntryTarget` and existing diagnostic translation;
follow filtering; intermediate-directory eligibility; host/native physical
path selection and canonicalization fallback; candidate label/site creation.
Return existing safe codes, paths, and sites. Do not turn inspection errors
into skips or skip validation merely because exclusion will later match.
3. Replace that inline body with the helper call and candidate insertion.
Leave pattern validation, `explicitGlobCandidate`, directory enumeration,
per-segment union/sorting, final-file validation, exclusions, requiredness,
and source-admission timing in the selector.
4. Keep directory entry preparation separate. Reuse existing filesystem/link
primitives; do not introduce a generic entry strategy or unify differing
missing-target, literal-link, filtering, or recursion rules. Remove imports
only when genuinely unused after extraction.
Acceptance: the selector now expresses traversal without embedding preparation
details. Reuse `glob_selection_test.go`, `glob_test.go`, filesystem-link tests,
and the preceding sprint's directory-phase regressions. They must still cover
literal hidden/link components versus wildcard filtering, aliases and native
spelling, escaping/cyclic links, matched-directory errors before exclusion,
selection order, and bounded accounting. Add a behavioral case only for a gap
identified during extraction, not a unit test asserting the new helper exists.
Focused check:
`go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'`.
## Stage 2 — Consolidate The Engine Defaults Representation
Finding: **F07**, engine representation. Owners: `internal/engine/import_spec.go`,
`load.go`, `layered.go`, `load_request.go`, `acquisition.go`, `options.go`, and
their callers/fixtures. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and `git diff --check`. Root facade callers remain pending Stage 3.
1. Retain engine `ImportDefaults` with its existing five exported fields.
Remove private `importDefaults`, without a compatibility alias. Update
engine option/request/acquisition fields, helper signatures, readers, and
fixture literals to use the canonical type and exported default-field names.
Private containing fields such as `loadRequest.importDefaults` can keep their
names. Do not rename the distinct fields of `importDeclarationSpec` or
`resolvedImportPolicy` just because they have similar meanings.
2. Expose `ImportDefaults ImportDefaults` on engine `Options[T]` and
`LayeredOptions[T]`. Remove `OptionsWithImportDefaults`,
`LayeredOptionsWithImportDefaults`, and `privateImportDefaults`. Stage 3 changes
root callers; a temporary compile failure is preferable to an interim bridge.
3. Adapt `snapshotImportDefaults` to the canonical type, retaining nil boolean
pointers and nil versus non-nil empty pattern. Keep `cloneBool` and
`cloneStrings` where they still have real consumers. Keep current snapshot
call sites until Stage 3 establishes their final ownership placement.
4. Keep effective declaration policy resolution and validation semantics intact:
explicit false beats defaults, each control resolves independently, and
selector-specific applicability and depth checks stay unchanged. Do not
combine defaults with resolved policy or registration compilation.
Acceptance: one engine input-defaults type reaches every production path and
fixture; no equivalent private record or alias remains. Inspect ordinary,
layered, transitive-import, directory/glob, and test-helper call sites. The root
cutover and removal of redundant snapshots are explicitly pending until Stage 3.
Run the import/default focused tests after Stage 3 if the root cutover prevents
building the package here; do not report that deferred check as passed.
## Stage 3 — Simplify Facade Defaults Construction And Snapshot Ownership
Finding: **F07**, defaults handoff. Owners: root `conversion.go`, `lifecycle.go`,
`layered.go`; engine defaults/preflight/coordinator owners; `facade_import_defaults_test.go`
and `internal/engine/import_spec_test.go`. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and `git diff --check`.
1. Set engine `ImportDefaults` fields directly in root `toEngineOptions` and
`toEngineLayeredOptions`. Retain `toEngineImportDefaults` as the explicit
root-to-engine type mapping, but map its booleans/pattern without copies.
Remove root `cloneImportDefaultBool` and `cloneImportDefaultStrings` after
confirming no other caller. Keep public root types and declaration contracts.
2. Keep `snapshotImportDefaults` in `loadSettings` as the load-entry ownership
operation for both APIs. Remove repeated snapshots in engine `LoadLayered`
request construction, `executeLoadRequest` acquisition-policy construction,
and `resolveImportPolicy`. Carry the owned defaults read-only thereafter.
Review all callers of those helpers, including test setup; direct declaration
resolution can read its input because it returns its own concrete policy.
3. Keep per-policy copies of the selected pattern, exclusions, and destination.
Do not borrow caller-owned storage beyond preflight, mutate the shared owned
defaults, or remove copying from unrelated option/record paths. Each new load
snapshots its own current inputs; add no reuse cache or ownership flag.
4. Replace the private conversion-snapshot fragment in
`TestImportDefaultsPreserveEmptyPatternAndRequiredness`, which converts options,
mutates inputs, and calls engine `Load`. It should no longer demand a snapshot
from a private conversion helper. Keep that test's public nil/empty,
requiredness, and inspection-evidence checks.
5. Protect the public snapshot contract with a compact ordinary/layered table:
use a main document with a version and imports, enable
`SourceVersionModeIfPresent`, and use a configuration type with version and
imported-value fields. In the main version-check callback, mutate the caller's
recursive default from true to false and its existing pattern element from
`*.cfg` to `*.skip`. This callback runs after preflight but before imported
discovery. Verify the original recursive/pattern selection still supplies a
value from a nested imported file. Use existing fixture helpers and no
concurrent mutation. A subsequent load with those changed defaults must omit
that imported contribution rather than reuse the prior snapshot. Keep fixtures
reusable or create fresh readers for each load.
6. Reuse or extend the small snapshot/policy tests for all four optional booleans,
explicit false, pattern mutation, nil, and non-nil empty pattern. Preserve
existing invalid-pattern-before-read/hook tests and declaration override,
transitive, underlay, and selected-overlay coverage. Do not assert exact copy
counts, pointer identities, or the private conversion boundary as public policy.
Acceptance: the engine/facade cutover builds; public defaults remain detached
before acquisition/callbacks, reused loads remain independent, and only genuine
ownership copies remain. Confirm the absence of full defaults snapshots at the
three removed downstream sites by source review, not production instrumentation.
Focused checks:
`go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and
`go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`.
## Stage 4 — Construct Placement Directly Across The Facade
Finding: **F07**, placement handoff. Owners: engine `destination.go`, `load.go`,
`layered.go`, normalized input and placement consumers; root `lifecycle.go`,
`layered.go`, and placement fixtures. Status: **complete**. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, and `git diff --check`.
1. Rename the existing engine enum and its constants to `PlacementMode`,
`PlacementModeMapping`, and `PlacementModeSequenceItem`, preserving underlying
`uint8` values and zero semantics. Use the same type throughout engine
placement parsing, normalization, acquisition, source evidence, and version
projection. Do not add a second raw-mode record or type alias.
2. Expose engine `Options[T].Destination` and `.PlacementMode`, and
`Input.PlacementMode`. Normalized `loadInput` and other private owner fields
can retain their names. Populate the normalized input from those fields.
Remove `OptionsWithPlacement` and `InputWithPlacement` and migrate all callers,
including the direct engine construction in root `records_test.go`.
3. In root `toEngineOptions` and `toEngineInput`, set destination using the existing
`toEnginePath` conversion and placement using
`engine.PlacementMode(options.PlacementMode)` or its input equivalent.
Preserve invalid enum values for `normalizeInput` to diagnose before reads.
Do not add validation/defaulting to the facade or a new setter/cast function.
4. Preserve normalization of literal/parsed destination keys, root-item rejection,
wrapper accounting, mounted/item source coordinates, and version projection.
Root `Path` is immutable and `toEnginePath` carries its private engine value;
keep normalization and actual mutable ownership copies. Do not redesign path
conversion or add redundant clones solely to replace the deleted decorator.
5. Leave source-result transport such as `SourceInfoPlacementModeForFacade`,
source-version conversion, sequence registration preparation, and opaque
records alone. They are not input setters targeted by this step. Adapt tests
mechanically to new engine construction without weakening public assertions.
Acceptance: both public loading APIs retain zero/mapping and sequence-item
placement, errors before input consumption, final values, source metadata, and
history. Reuse the public placement tests plus destination/accounting,
item-import, mounted-version, and invalid-option cases. Root signatures still
contain no engine types, and engine exports remain below the internal boundary.
Focused checks:
`go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'` and
`go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`.
## Stage 5 — Integrate, Document, And Verify The Result
Findings: final acceptance for **C03, F07**. Status: **complete**. Validation
passed on Linux/amd64 (Go 1.26.10):
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh`,
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and
`git diff --check`. Existing integration coverage was reviewed; no additional
case was needed.
1. Review both paths together: defaults resolving wildcard discovery in ordinary,
layered, and transitive imports; placement carrying the resulting sources
through composition and inspection; and failures stopping before later hooks.
Existing integration tests own these interactions. Add only a concrete missing
case rather than a second copy of the complete matrix or a new fixture framework.
2. Inspect the resulting handoffs. Confirm the glob selector no longer embeds
per-entry preparation, the engine has one defaults input type, direct fields
replaced the four input decorators, and the load snapshot is not repeated at
each private representation. Keep domain-specific policy/registration and
root conversion boundaries, necessary ownership copies, and existing budgets.
3. Update `docs/internal/imports.md` for the selector/preparation split and
`docs/internal/overview.md` for defaults conversion, preflight snapshot ownership,
and direct placement construction. Keep algorithms private and avoid public
performance or copy-count guarantees. No ADR/policy change is needed.
4. Replace C03/F07 detail in the first two `docs/roadmap/future.md` entries with
short pointers to `internal-boundaries.md`. Retain their heading anchors for
existing audit links; leave other future candidates unchanged. Correct old
roadmap references that would otherwise mistake this replacement plan for
the previous sprint's completed record: use
`bb5540b:docs/roadmap/implementation.md` for that historical plan. Do not reopen
the prior audit or retire unrelated planning records in this sprint.
5. Format changed Go files, finish deferred checks, run the final checks below,
and inspect the complete diff for API, dependency, semantics, or scope changes.
Record actual results and platform limitations here. Commit focused nonempty
units and hand off a clean workspace; do not create empty validation commits.
Final validation, following the existing development guide:
```sh
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...
git diff --check
```
The everyday check includes consumers, documentation links, and public/dependency
boundary tests. Confirm those existing tests ran rather than adding redundant
final invocations. Report filesystem/platform skips accurately; express macOS
validation, CI completion, release publication, extended fuzz campaigns, and
benchmarks are not additional acceptance requirements.
No product or architectural question remains open. The concrete choices above
complete the plan without changing the roadmap's public contracts; private
formatting and file organization can follow local idiom.

View File

@@ -1,186 +0,0 @@
# Import Discovery And Facade Boundary Simplification
Status: target defined; execution is tracked in the [implementation plan](implementation.md).
Baseline: `80cf23e7102fca2c5d7c7621e75f930a1ef1ecad`.
This roadmap brings together the two refactors deferred until after the audit
remediation sprint: glob entry preparation (**C03**) and facade-to-engine
transport simplification (**F07**). Their original motivation is retained in the
[glob](future.md#1-separate-glob-entry-preparation-from-traversal) and
[transport](future.md#2-simplify-redundant-facade-to-engine-transport) future-work
entries and the [consolidated audit](audit.md).
The goal is clearer responsibility and ownership with fewer redundant internal
representations. Both changes preserve the public API and configuration model.
They are independent work areas within one maintenance effort, not a new loading
workflow. The [implementation plan](implementation.md) owns concrete edits,
stages, validation commands, and execution progress.
## Baseline And Targeted End State
The preceding sprint repaired directory-failure classification and shared link
dependency evaluation, clarified acquisition handoffs, and removed unused
placement bridges. Those changes provide the baseline; this work does not repeat
or redesign them.
| Area | Current baseline | Target |
| --- | --- | --- |
| Glob discovery | `selectGlobImportCandidates` interleaves traversal and set operations with substantial per-entry preparation. | The selector coordinates discovery; a cohesive private operation prepares a wildcard-discovered entry. |
| Facade transport | Import defaults pass through root conversion, an exported engine transport record, an equivalent private engine record, and repeated snapshots. Placement construction also uses setter-style bridges. | Root-owned public records convert explicitly to one engine defaults representation, with straightforward engine option/input construction and identifiable ownership copies. |
The [architecture policy](../policy/architecture.md), [configuration
contract](../config.md), and public declarations remain authoritative. [ADR
0006](../adr/0006-public-facade.md) continues to govern the facade boundary. These
refactors do not require a new architectural decision or changes to its accepted
decision. The [component overview](../internal/overview.md) and [import
internals](../internal/imports.md) own implemented structure.
## Glob Entry Preparation
### Target Responsibility Split
In [glob.go](../../internal/engine/glob.go), the selector continues to own pattern
validation, component advancement, literal-segment handling, directory
enumeration and its accounting, candidate union and sorting, final target
validation, exclusions, and requiredness.
A private glob-specific preparation operation owns the patterned branch's work
for one directory entry: matching and hidden-entry filtering, lexical/access path
construction, containment checks, target inspection and discovered-link policy,
intermediate-directory eligibility, host/native access-path selection, labels,
and candidate construction. Its result distinguishes a selected candidate, an
ordinary skip, and a diagnostic. It receives the facts needed for that entry
rather than mutating the selector's candidate set or surrounding traversal state.
Use the existing filesystem access and link-resolution helpers. Directory
selection already has `prepareImportDirectoryEntry`; it remains a separate owner
because its recursion, matching, absence handling, and traversal state differ.
Share a smaller operation only where it actually expresses the same semantics
and reduces duplication. No common visitor or discovery strategy is needed for
this extraction.
### Behavior To Preserve
Preserve the current distinctions and processing order documented by
[explicit imports](../config.md#explicit-imports) and exercised by the selection
tests:
- Literal components remain explicit selections; discovery filters apply at
their existing wildcard boundaries. Moving matching or hidden filtering after
filesystem inspection would change which errors are observable.
- Keep lexical candidate identity separate from resolved access identity.
Native source identity and relative imports retain namespace spelling; host
access retains its existing canonicalization and containment behavior.
- Keep discovered-link follow rules, intermediate-directory eligibility,
missing-target handling, and structured diagnostic sites/classifications.
Do not make glob and directory rules identical as a side effect of extraction.
- Preserve deterministic candidate and error order, union/deduplication,
directory-entry accounting, and the existing point of source admission.
Final selected-file validation remains before exclusions where it is today;
exclusions do not hide discovery or resource errors.
- Retain the preceding sprint's distinction between missing-on-open directories
and failures after opening, along with operation-local completed-link reuse.
The intended benefit is that a maintainer can understand traversal separately
from entry eligibility and identity preparation. This is a readability change;
it does not select a different search algorithm or claim a speedup.
## Facade-To-Engine Transport
### Target Representations
The root keeps its public `ImportDefaults`, `PlacementMode`, `Options`, `Input`,
and `LayeredOptions` types. Explicit conversions remain appropriate at this
boundary; consumers must not acquire engine type identity, fields, or methods.
Inside the engine, consolidate the equivalent `ImportDefaults` transport and
private `importDefaults` records into one presence-aware input-defaults
representation. Carry that representation through the ordinary and layered
options, normalized request, and acquisition policy instead of translating the
same five fields between equivalent engine records.
Keep `resolvedImportPolicy` distinct: it represents concrete per-declaration
choices, including selector-specific rules, rather than another copy of optional
load-wide input. Likewise, registration compilation and the normalized loading
request have real responsibilities and are not replacement targets.
Make placement and defaults straightforward to construct on the existing engine
option/input records. Remove decorator-style bridges made unnecessary by that
construction surface. Exporting fields or types within `internal/engine` where
needed by the root converter does not make them part of ConfigKit's supported
public API. Keep explicit enum conversion and preserve unknown values until the
existing option validation can report them; conversion must not silently choose
a default or move validation into facade setters.
### Ownership And Copying
Make the actual mutable ownership boundaries identifiable. Public load calls
continue to snapshot pointed-to default booleans and the pattern slice before
source acquisition, as specified by the [public
declaration](../../lifecycle.go). Preserve omitted, explicit false, nil pattern,
and non-nil empty pattern as distinct inputs where they are distinct today.
Review the entire handoff, not just the duplicate type definitions: root
conversion in [conversion.go](../../conversion.go), ordinary construction in
[lifecycle.go](../../lifecycle.go), layered construction in
[layered.go](../../layered.go), engine options and normalization, acquisition
policy construction, and declaration resolution in
[import_spec.go](../../internal/engine/import_spec.go).
Remove snapshots that merely recopy already-owned read-only defaults as they
cross those private representations. Keep copies needed when establishing
independent mutable ownership, including any independently owned resolved
policy. The target is explainable ownership and less redundant work, not an exact
copy count or removal of copying throughout the library. Shared defaults within
one load can be read-only by implementation discipline; this creates no new
caller immutability or concurrency requirement.
Preserve ordinary and layered option preflight before source reads and application
hooks, independent declaration overrides, transitive/underlay propagation,
selected-overlay behavior, destination normalization, and invalid-enum
diagnostics. Keep callback, source, path, secret, and returned-record conversions
whose ownership or domain meaning is not redundant transport.
The intended benefit is fewer concepts, field mappings, and construction paths
to maintain. Reduced copying is useful but does not establish an end-to-end
performance guarantee.
## Documentation Ownership
Internal documentation describes the resulting discovery and transport
boundaries once implemented. Public declarations continue to own the existing
snapshot and type contracts; this effort selects no new public behavior.
The corresponding C03/F07 future-work entries serve as navigation to this
roadmap rather than parallel target or progress inventories. Unrelated future
candidates remain separate. The implementation plan owns the documentation
updates and retirement of replaced private representations and helpers.
## Validation And Completion
Follow the [testing policy](../policy/testing.md) and [development
guide](../development.md#baseline-validation). Reuse the existing glob selection,
directory/link failure, import-default, placement, public facade, and dependency
boundary coverage. Add a focused behavioral case only where a concrete
extraction or ownership risk lacks protection.
For discovery, verify literal versus wildcard filters, host/native identity,
contained and escaping links, optional absence versus read/close failures,
ordering, exclusions, regular-file checks, and existing resource accounting. For
transport, verify nil/empty and explicit-false distinctions, mutation of caller
default storage after the load snapshot through a supported callback, invalid
options before input consumption, both loading APIs, and transitive/underlay
defaults. Existing conversion-focused tests may change with private construction;
preserve the useful ownership behavior rather than fossilizing a helper's shape.
Completion requires an easier-to-follow selector, one engine input-defaults
representation, fewer redundant mapping/construction sites, justified remaining
copies, and preserved public behavior under the relevant checks. Review source
and ownership flow as well as passing tests; moving complexity into a helper or
renaming a duplicate record alone is insufficient.
No benchmark campaign, complexity score, allocation threshold, exact filesystem
call trace, new resource limit, or additional release gate is required. This work
adds no dependencies, caches, background activity, or new consumer controls, and
does not reopen other audit findings or deferred performance experiments. Routine
helper signatures, names, and file placement belong to the implementation plan;
no significant product or architectural decision remains open for this scope.

View File

@@ -1,276 +0,0 @@
# Audit Remediation Roadmap
Status: target accepted; completed execution is recorded in the historical plan
at `bb5540b:docs/roadmap/implementation.md`.
Prepared: 2026-10-25. Planning baseline: `e1e94ee`.
This roadmap describes the target state and related work for the 29 entries
selected in the [consolidated audit review](audit.md#settled-scope-decisions).
It is intended for maintainers reviewing the remediation's scope and architecture.
## Document Ownership
- [audit.md](audit.md) owns finding IDs, original evidence, detailed remedies and
acceptance cases, and the settled scope decisions. Its evidence has not been
independently reproduced by writing this roadmap.
- This document owns the combined target state, work areas, and design intent.
Finding IDs below provide traceability without repeating the full audit inventory.
- The historical implementation plan at
`bb5540b:docs/roadmap/implementation.md` records concrete stages, private
implementation choices, edits, validation commands, and completion evidence.
The [configuration contract](../config.md), public Go declarations, and
[architecture policy](../policy/architecture.md) remain the owners of behavior
and guarantees. Update their applicable implementation documentation when work
lands; this planning document does not redefine their contracts.
## Current Baseline And Targeted End State
The existing loading model remains the foundation. Public loading APIs delegate
to the shared engine lifecycle; acquisition retains source scopes for composition,
then precedence, inheritance, decoding, CLI overrides, application resolution,
secret lookup, and validation produce a detached result with inspection metadata.
The [component overview](../internal/overview.md) describes the current owners.
The audits identify failures at particular traversal, lifetime, error, and
accounting boundaries, alongside redundant representations and repeated work.
The target is to repair those boundaries and simplify their implementation while
preserving ConfigKit's public API and configuration model.
| Current gap | Targeted outcome |
| --- | --- |
| Recursive descriptors can fail to terminate, failed dynamic builds can leave partial state, and slice aliases can be mistaken for cycles. | Traversal terminates for the reported cases, failed construction leaves reusable state valid, and supported acyclic values remain accepted. |
| Callback and owned-root cleanup can be skipped during panic unwinding; directory errors lose their failure phase. | Cleanup follows the lifetime of each owned resource, and selected-directory failures retain the information needed for correct error handling. |
| Finite symlink dependencies can be expanded repeatedly. | Completed dependencies are reused within a resolution while active cycles remain detectable. |
| Projection reservations and published history can escape existing accounting. | Existing budgets govern the relevant allocations and complete retained document graph. |
| Collection-policy work repeatedly scans or copies unrelated state. | Policy preparation, application, and retained evidence have clear ownership, with work localized to affected policy. |
| Acquisition has redundant paths and difficult handoffs; several private representations have no production consumer. | Production paths and their tests share the active protocol, with cohesive handoffs and unused representations removed. |
| Some lookups repeat full scans, and some tests assert incidental representation or miss meaningful mutations. | Local indexing avoids the identified repeated work, and tests protect observable contracts at useful boundaries. |
This is a bounded remediation effort, not a redesign of configuration semantics.
Correcting erroneous acceptance, rejection, cleanup, or diagnostics in the
reported cases restores existing contracts. It does not select new loading APIs,
configuration options, limits, background work, or persistent caches.
## Selected Work
The following areas account for all 29 selected entries. Detailed examples and
acceptance cases remain with their linked audit group.
### 1. Safe Typed-Value Traversal And Descriptor Reuse
Selected entries: **A01, A02, A03, A04, A05** in
[schema and typed values](audit.md#a-schema-and-typed-values).
Make pointer-only descriptor traversal terminate in decoding and path resolution,
while preserving supported null values and ordinary recursive structures. Make
dynamic descriptor construction transactional: a failed build must not leave
partial descriptors available to subsequent operations. Staging successful
additions or rolling back the complete failed addition set are implementation
choices; both must handle recursive references and preserve prior valid entries.
Correct active-path cycle detection to distinguish finite slice views sharing
storage from actual cycles. Keep detachment and nil/empty behavior intact across
the callers that share this traversal machinery.
Enforce existing projection budgets before large container reservations. Include
map-key materialization and overflow-safe lower-bound checks in the review; a
check after the reservation does not address the reported failure. This work
does not introduce a general heap limit or reject large containers independently
of existing budgets.
Dynamic-type eligibility reuse depends on safe descriptor construction and retains
completed successful checks within the value operation. Keep failure diagnostics
specific to their current path and distinguish completed results from traversal visits.
This removes repeated descriptor-graph walks without a cross-load cache.
### 2. Callback, Filesystem, And Dependency Lifetimes
Selected entries: **B01, B02** in [callback lifetime](audit.md#b-callback-and-filesystem-lifetime)
and **C01, C02** in [filesystem traversal](audit.md#c-import-selection-and-link-resolution).
Tie callback-context deactivation to the callback invocation with deferred cleanup
so it runs on normal return and panic unwinding. Independently give the owned
import root a deferred cleanup fallback. Preserve normal close-error handling,
caller ownership, and propagation of the original callback panic. Fixing either
state lifetime or handle lifetime does not fix the other.
Preserve the phase of a directory failure. A genuinely absent directory before
successful opening remains subject to existing permissiveness rules; a later
read or close failure must not be treated as absence. Apply this distinction to
both supported directory-reading paths. Strengthen the shared filesystem tests
to check each handle's closure and structured, non-disclosing diagnostics,
including partial reads and failures masked by another successful directory.
Do not change regular-file close-error policy as a side effect.
Avoid repeatedly expanding completed finite symlink dependencies in both host
and native filesystem resolution. Use operation-local memoization of completed
targets, or an equivalent dependency evaluation, with keys that respect the
resolution namespace. Keep active-path cycle detection separate from completed
work. Preserve containment, supported finite reuse, and existing host-path
semantics. The fix must work for directory and glob selection without a new
arbitrary traversal cap or changed symlink defaults.
### 3. Retained History And Combination Evidence
Selected entries: **D01, D02, D03, D04** in
[history and retained state](audit.md#d-retained-history-and-unused-evidence).
Remove the unconsumed override-result pre-CLI copy and the unqueried published
structural-input copy. Preserve the separate pre-CLI state used by active replay
and the structural input still used before publication for metadata and secret
classification. Remove unused combination-evidence members and the work needed
solely to produce them, retaining projections, mappings, origins, and snapshots
that have active consumers.
With retained roots clarified, enforce the existing history allowance against
the complete graph published through inspection, including replay and inheritance
state and publication-created copies. Count shared nodes once by pointer identity;
distinct retained copies still consume the allowance. Account for required copies
before creating them where practical. Removing an unused tree alone does not
resolve the accounting omissions.
Keep required before/after evidence and existing inspection behavior. Validate
the complete publication path with small controlled allowances, including the
interaction of CLI, resolution replay, and inheritance, rather than relying only
on tests of an isolated counting helper.
### 4. Collection-Policy Preparation And Application
Selected entries: **E01, E02, E03** in
[collection policy and projection](audit.md#e-collection-processing-and-efficiency).
Distinguish the existing policy state used for lookup, changes introduced by an
incoming tier, and evidence retained by the assembly. Prepare incoming claims and
declarations against read-only lower state, then apply the owned changes at the
appropriate combination boundary. Avoid copying every unrelated policy for
small tiers and scanning all policies for each independent replacement.
Use a semantic-path index, localized updates, or equivalent bounded lookup to
find affected policy. Preserve retirement timing, policy established by the
incoming contribution, pending declarations, persistent explicit modes, and
nested record ownership. Copying remains appropriate wherever mutable ownership
requires it. Removing only one duplicate clone or shortening a function does not
address the identified growth patterns.
Clarify preparation, evidence application, and finalization responsibilities.
Share straightforward history-descriptor translation where useful; evaluate
shared record-selection logic while keeping overlay and inheritance origin and
mode lifetimes distinct. Retain the common sequence-combination owner. This work
does not call for a generic merging framework.
### 5. Active Acquisition Handoffs And Private Cleanup
Selected entries: **F01, F02, F03, F04, F05, F06** in
[acquisition, cleanup, and test quality](audit.md#f-remaining-maintenance-and-test-improvements).
Remove the test-only eager acquisition mode so acquisition retains scopes through
the active production protocol. Tests that need composition should invoke the
actual composition boundary explicitly. Retire the parallel test-only import
parser and route relevant fixtures through the active directive parser or the
appropriate declaration boundary, retaining malformed-input coverage.
Clarify active acquisition and coordinator handoffs around cohesive tasks:
parsed-input directive/version handling, standalone placement, and repeated tier
application/projection. Use explicit inputs and results consistent with existing
state owners. Extracting helpers that continue to share surrounding mutable state
does not establish a clearer handoff. Preserve ordinary/layered differences,
overlay and diff behavior, source identity, callback timing, diagnostics, and
accounting scopes.
Remove the disconnected root record-clone family, uncalled engine helpers, and
unread private transport/result members identified in the audit. Reconfirm
references on the implementation baseline, including function values and relevant
build configurations. Preserve live conversion paths and validation side effects;
the audit's absence of field reads is not a general rule for deleting state.
Replace meaningful ownership coverage through the public boundary before removing
tests that only exercise disconnected helpers.
### 6. Local Lookup Improvements
Selected entries: **E04, E05** in
[collection policy and projection](audit.md#e-collection-processing-and-efficiency)
and **F08** in [secret-failure lookup](audit.md#f08-index-secret-failure-location-queries-lazily).
Reuse a local document lookup or equivalent traversal for tier-history projection
instead of rescanning wide mappings for each sequence. Preserve authored
coordinates, sparse projections, and evidence ownership.
Restore compound identity fields using indexed mapping positions and membership
checks rather than rescanning child and merged mappings for every field. Preserve
child-authored values and locations, mapping order, and detached nodes.
Build secret-failure location lookup state lazily when failures need it. Preserve
all diagnostics, nearest-location fallbacks, and confidentiality; successful
loads should not incur an unnecessary index merely to support failure reporting.
These changes address specific repeated work and do not promise a measured
startup improvement.
### 7. Tests That Protect The Intended Behavior
Selected entries: **F09, F10, F11, F12** in
[acquisition, cleanup, and test quality](audit.md#f-remaining-maintenance-and-test-improvements),
in addition to the regression coverage attached to each reported defect.
Cover historical-path enumeration through the public API, including removed
paths, queryability, ordering, and detached returned slices. Use those assertions
to replace private representation checks where they address the same risk.
Strengthen fuzz detachment checks by mutating existing descendants or in-length
elements, so spare capacity cannot hide aliases. Replace exact filesystem stat
call sequences with observable candidate ordering and meaningful failure
selection, retaining relevant assertions such as not opening excluded inputs.
Consolidate facade scaffolding around public construction, loading, errors,
ownership, and lifecycle behavior. Preserve distinct integration risks and
reader-byte snapshot coverage. Existing coverage percentages or a desired test
count are not acceptance criteria for this work.
## Implementation Plan
The historical implementation plan at `bb5540b:docs/roadmap/implementation.md`
defines the sequential, Luna-scoped stages and their dependencies, including
coordinated changes to descriptor state, history ownership/accounting, collection
policy, acquisition, and replacement public coverage. It also records validation
commands and the complete finding-to-stage map. The audit remains the owner of
original evidence and detailed acceptance cases.
## Validation And Completion
Validation follows the audit's detailed acceptance cases and the
[testing policy](../policy/testing.md). For fatal or hanging regressions, use bounded
subprocesses; for budget cases, use small controlled limits. For symlink expansion
and repeated lookup work, prefer bounded operation evidence and algorithm review
over timing thresholds. Use real filesystem fixtures where filesystem semantics
matter and narrow fault injection for read/close failures. State platform limits.
Applicable development checks come from the [development guide](../development.md),
including appropriate race coverage for changed ownership and lifetime boundaries.
The historical implementation plan owns their execution. Existing audit results
are baseline evidence, not validation of the eventual changes. No performance
benchmark campaign is required by this roadmap, and no new benchmark results are
claimed.
Completion means all 29 selected findings have implementation and acceptance
evidence, the affected public behavior remains consistent with its contracts,
and canonical internal documentation describes the resulting owners. The
historical implementation plan records execution status without duplicate
progress inventories. Retire completed planning records under the normal
documentation lifecycle.
## Scope Boundaries And Decision Status
All three audit scope questions are settled. No significant architectural or
product decision remains open for this roadmap. The historical implementation
plan records private design choices and sequencing, subject to the acceptance
cases above.
If that work exposes a need to change a public contract, bring the specific
decision back for review rather than treating it as an implicit part of remediation.
The glob preparation refactor (C03) and wider facade transport simplification
(F07) have since been selected in the
[internal-boundary roadmap](internal-boundaries.md). Other deferred features,
clone-traversal redesign, and broader performance experiments remain outside
this effort. Keep
existing ownership boundaries and loading APIs; no ADR or architecture-policy
change is proposed by this roadmap.

View File

@@ -6,7 +6,7 @@ import (
"os" "os"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func ExampleLoad() { func ExampleLoad() {
@@ -186,7 +186,8 @@ func ExampleOverride() {
}, },
Overrides: overrides, 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> // Output: 0 --workers <nil>
} }

View File

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// Artifact is a Narratio-shaped generated output declaration. // Artifact is a Narratio-shaped generated output declaration.
@@ -167,7 +167,7 @@ func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Overri
resolveNotariusReference(configuration, resolution, invocationDir) resolveNotariusReference(configuration, resolution, invocationDir)
return nil 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) { if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
return return
} }
info, found := resolution.InputInfo(path) info, found := resolution.EntryInfo(path)
if !found { if !found {
resolution.Warning(path, "relative reference has no entry provenance") resolution.Warning(path, "relative reference has no entry provenance")
return return

View File

@@ -11,8 +11,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_narratio() { func Example_narratio() {
@@ -82,7 +82,7 @@ func Example_weatherStartup() {
defer os.RemoveAll(directory) defer os.RemoveAll(directory)
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600) _ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\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 var reportOutput bytes.Buffer
_, reportWriteErr := report.WriteTo(&reportOutput) _, reportWriteErr := report.WriteTo(&reportOutput)

View File

@@ -6,7 +6,7 @@ import (
"io/fs" "io/fs"
"path" "path"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// CollectionBundleConfig is a representative application-owned collection // CollectionBundleConfig is a representative application-owned collection
@@ -86,7 +86,7 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
continue continue
} }
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file") 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 { if !found || len(info.Origin.Locations) != 1 {
return fmt.Errorf("prompt resource has no single declaring source") return fmt.Errorf("prompt resource has no single declaring source")
} }
@@ -104,9 +104,9 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
} }
prompt.ResourceText = string(data) prompt.ResourceText = string(data)
collection.Prompts[promptID] = prompt collection.Prompts[promptID] = prompt
resolution.Record( resolution.RecordNormalization(
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"), 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 configuration.Collections[collectionID] = collection

View File

@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_inputDestination() { func Example_inputDestination() {
@@ -60,7 +60,7 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
} }
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version")) 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) t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
} }
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version") collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")

View File

@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// FileComparisonConfig is a small application-owned shape for comparing // FileComparisonConfig is a small application-owned shape for comparing
@@ -101,8 +101,8 @@ func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resoluti
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port) url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
if endpoint.URL != url { if endpoint.URL != url {
endpoint.URL = url endpoint.URL = url
resolution.Record(configkit.Keys("service", "endpoint", "url"), resolution.RecordNormalization(configkit.Keys("service", "endpoint", "url"),
configkit.ChangeNormalization, "derived endpoint URL", "derived endpoint URL",
configkit.Keys("service", "endpoint", "host"), configkit.Keys("service", "endpoint", "host"),
configkit.Keys("service", "endpoint", "port")) configkit.Keys("service", "endpoint", "port"))
} }

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func ExampleCompareFiles() { func ExampleCompareFiles() {

View File

@@ -5,7 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type workingDirectoryPresetConfig struct { type workingDirectoryPresetConfig struct {
@@ -40,6 +40,7 @@ func Example_workingDirectoryPreset() {
fmt.Println(err) fmt.Println(err)
return 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 // Output: example service.yml
} }

View File

@@ -1,6 +1,6 @@
package consumers package consumers
import configkit "gitea.maximumdirect.net/eric/ConfigKit" import configkit "go.maximumdirect.net/configkit"
// PresetCatalog is ordinary application data; no inheritance fields or // PresetCatalog is ordinary application data; no inheritance fields or
// registration hooks are required in the typed configuration. // registration hooks are required in the typed configuration.

View File

@@ -3,8 +3,8 @@ package consumers_test
import ( import (
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) { func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"io/fs" "io/fs"
"path" "path"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version //go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version

View File

@@ -6,8 +6,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_nativeFilesystem() { func Example_nativeFilesystem() {
@@ -26,7 +26,7 @@ func Example_rootRelativeImports() {
fmt.Println(err) fmt.Println(err)
return 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) 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> // Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
} }
@@ -68,7 +68,7 @@ func Example_sourceVersionAdoption() {
} }
checks, redacted := 0, true checks, redacted := 0, true
for _, source := range result.Sources() { for _, source := range result.Sources() {
if check := source.VersionCheck; check != nil { if check := source.VersionEvidence; check != nil {
checks++ checks++
redacted = redacted && check.Redacted && check.Version.Text == "" redacted = redacted && check.Redacted && check.Version.Text == ""
} }
@@ -95,7 +95,7 @@ func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
t.Fatalf("versioned service sources = %#v", sources) t.Fatalf("versioned service sources = %#v", sources)
} }
for _, source := range sources { for _, source := range sources {
check := source.VersionCheck check := source.VersionEvidence
if check == nil || check.Path.String() != "/version" || !check.Version.Present || if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
!check.Redacted || check.Version.Text != "" { !check.Redacted || check.Version.Text != "" {
t.Fatalf("safe source version evidence = %#v", sources) t.Fatalf("safe source version evidence = %#v", sources)
@@ -119,7 +119,7 @@ func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
} }
for _, key := range []string{"enabled", "workers"} { for _, key := range []string{"enabled", "workers"} {
info, found := result.Metadata().Lookup(configkit.Keys(key)) 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) t.Fatalf("%s presence = %#v, %v", key, info, found)
} }
} }

View File

@@ -3,7 +3,7 @@ package consumers
import ( import (
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// PromptCatalogConfig is an application-owned prompt collection shape. // PromptCatalogConfig is an application-owned prompt collection shape.

View File

@@ -8,8 +8,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_identifiedPromptCatalog() { func Example_identifiedPromptCatalog() {
@@ -24,8 +24,8 @@ func Example_identifiedPromptCatalog() {
} }
emptySearches := 0 emptySearches := 0
for _, source := range result.Sources() { for _, source := range result.Sources() {
for _, selection := range source.ImportSelections { for _, selection := range source.EmptyImportSelections {
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone { if selection.Selector == configkit.ImportSelectorDirectory {
emptySearches++ emptySearches++
} }
} }

View File

@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// SequenceCatalogConfig is a source-backed prompt catalog with an application // SequenceCatalogConfig is a source-backed prompt catalog with an application
@@ -43,7 +43,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root)) filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem} filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
mainSearch := configkit.SearchConfig("", configkit.Searches( mainSearch := configkit.SearchConfig("", configkit.Searches(
configkit.Paths(absoluteMain), configkit.SearchPaths(absoluteMain),
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{ configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))}, 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 { Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
configuration.ResolvedLabel = "for:" + configuration.RunLabel 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")) "derive the resolved label from the selected run label", configkit.Keys("run_label"))
return nil return nil
}, },

View File

@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" "go.maximumdirect.net/configkit/examples/consumers"
) )
func Example_sequenceCatalog() { func Example_sequenceCatalog() {

View File

@@ -4,7 +4,7 @@ import (
"errors" "errors"
"io/fs" "io/fs"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
// VersionedServiceConfig demonstrates a mounted main input whose source-local // VersionedServiceConfig demonstrates a mounted main input whose source-local
@@ -47,7 +47,7 @@ func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configk
return nil return nil
}, },
}, },
Secret: func(path configkit.Path) bool { Sensitive: func(path configkit.Path) bool {
return path.String() == "/service/version" return path.String() == "/service/version"
}, },
} }

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
var ( var (
@@ -23,7 +23,7 @@ var (
func TestFacadeValuesRemainRootOwned(t *testing.T) { func TestFacadeValuesRemainRootOwned(t *testing.T) {
t.Parallel() t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit" const packagePath = "go.maximumdirect.net/configkit"
for _, candidate := range []reflect.Type{ for _, candidate := range []reflect.Type{
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](), reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](), reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
@@ -140,7 +140,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
report, err := configkit.LoadSecretsDir(directory) report, err := configkit.LoadSecretsIntoEnvironment(directory)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -164,7 +164,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
t.Fatalf("report rendering = %q, %v", rendered.String(), err) t.Fatalf("report rendering = %q, %v", rendered.String(), err)
} }
failedReport, err := configkit.LoadSecretsDir("") failedReport, err := configkit.LoadSecretsIntoEnvironment("")
var secretsError *configkit.SecretsError var secretsError *configkit.SecretsError
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath { 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) t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
@@ -203,7 +203,7 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
if !resolution.IsExplicit(configkit.Keys("input")) { if !resolution.IsExplicit(configkit.Keys("input")) {
resolution.Error(configkit.Keys("input"), "input is required") 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 { if !ok || !input.Explicit {
resolution.Error(configkit.Keys("input"), "missing input provenance") resolution.Error(configkit.Keys("input"), "missing input provenance")
} }
@@ -245,6 +245,8 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
} }
for _, context := range []func(){ for _, context := range []func(){
func() { retainedResolution.IsExplicit(configkit.Keys("input")) }, 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() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
} { } {
func() { func() {

View File

@@ -7,7 +7,7 @@ import (
"io" "io"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
var ( var (

View File

@@ -0,0 +1,41 @@
package configkit_test
import (
"testing"
"testing/fstest"
configkit "go.maximumdirect.net/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

@@ -3,7 +3,7 @@ package configkit_test
import ( import (
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) { func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
@@ -24,10 +24,11 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
call func() call func()
}{ }{
{"IsExplicit", func() { state.resolution.IsExplicit(path) }}, {"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }}, {"EntryInfo", func() { state.resolution.EntryInfo(path) }},
{"Source", func() { state.resolution.Source("source") }}, {"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("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.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }}, {"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},

View File

@@ -14,7 +14,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) { func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
@@ -352,7 +352,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error { Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
callbacks.resolve++ callbacks.resolve++
resourcePath := configkit.Keys("catalog", "entries", "item", "resource") resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
input, ok := resolution.InputInfo(resourcePath) input, ok := resolution.EntryInfo(resourcePath)
if !ok || len(input.Origin.Locations) != 1 { if !ok || len(input.Origin.Locations) != 1 {
return fmt.Errorf("mounted resource input has no single source origin") 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 := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
entry["resource_text"] = string(data) 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 return nil
}, },
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ }, Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() }, Sensitive: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
}, },
}) })
if err != nil { if err != nil {
@@ -405,7 +405,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
if err != nil || string(data) != "source-relative summary" { if err != nil || string(data) != "source-relative summary" {
t.Fatalf("completed relative resource = %q, %v", data, err) 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) t.Fatalf("mounted title metadata = %#v, %v", info, ok)
} }
if history := result.Metadata().History(titlePath); len(history) < 2 { 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 "go.maximumdirect.net/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

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type facadeDiffConfig struct { type facadeDiffConfig struct {
@@ -29,10 +29,10 @@ func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testin
t.Helper() t.Helper()
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")), result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ 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 { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Items[0] = "public scalar" 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 return nil
}, },
}}) }})
@@ -71,10 +71,10 @@ func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
Dynamic map[string]any `yaml:"dynamic"` Dynamic map[string]any `yaml:"dynamic"`
} }
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ 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 { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
delete(current.Dynamic, "entry") delete(current.Dynamic, "entry")
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry") resolution.RecordNormalization(configkit.Keys("dynamic", "entry"), "remove entry")
return nil return nil
}, },
}} }}
@@ -221,7 +221,7 @@ func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
resolve := func(value string) configkit.Options[config] { resolve := func(value string) configkit.Options[config] {
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error { return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
current.Count = len(value) + 8 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 return nil
}}} }}}
} }
@@ -281,7 +281,7 @@ func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) { func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
t.Setenv("FACADE_DIFF_SECRET", "credential-one") 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" return path.String() == "/secret"
}}} }}}
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions) 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"} names := []string{"config.yml"}
groups := []FilenameGroup{CustomFilenames(names...)} groups := []FilenameGroup{CustomFilenames(names...)}
directoryOptions := []DirectorySearchOptions{{Filenames: groups}} 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}) source := SearchConfig("", search, SearchOptions{Required: true})
paths[0] = laterPath paths[0] = laterPath
names[0] = "later.yml" names[0] = "later.yml"
groups[0] = CustomFilenames("later.yml") groups[0] = CustomFilenames("later.yml")
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml") directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
search = Paths(laterPath) search = SearchPaths(laterPath)
result, err := Load[facadeDiscoveryConfig](source) result, err := Load[facadeDiscoveryConfig](source)
if err != nil || result.Config.Value != "selected" { if err != nil || result.Config.Value != "selected" {
@@ -110,7 +110,7 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
t.Fatalf("ordered search sources = %#v", sources) 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 var loadErr *LoadError
if !errors.As(err, &loadErr) { if !errors.As(err, &loadErr) {
t.Fatalf("required exhausted search error = %v, want *LoadError", err) 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") mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n") underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
result, err := LoadLayered( result, err := LoadLayered(
Input{Source: SearchConfig("", Paths(mainPath))}, Input{Source: SearchConfig("", SearchPaths(mainPath))},
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{ Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
Filenames: []FilenameGroup{CustomFilenames("base.yml")}, Filenames: []FilenameGroup{CustomFilenames("base.yml")},
@@ -142,7 +142,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
t.Fatalf("layered search = %#v, %v", result, err) t.Fatalf("layered search = %#v, %v", result, err)
} }
sources := result.Sources() 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 { sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
t.Fatalf("layered search sources = %#v", sources) t.Fatalf("layered search sources = %#v", sources)
} }
@@ -157,7 +157,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
filesystem := &FilesystemContext{Root: root} filesystem := &FilesystemContext{Root: root}
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem} 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" { if err != nil || selected.Config.Value != "overlay" {
t.Fatalf("selected search with root-only context = %#v, %v", selected, err) 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) 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) searchedAbsent, err := Load(missing, options)
if err != nil { if err != nil {
t.Fatalf("exhausted search with root-only overlay context: %v", err) 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") inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
outsideRoot := t.TempDir() outsideRoot := t.TempDir()
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n") 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}}) _, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
var loadErr *LoadError var loadErr *LoadError
@@ -228,7 +228,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n") selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
writeFacadeImportFile(t, root, "later.yml", "value: later\n") writeFacadeImportFile(t, root, "later.yml", "value: later\n")
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0 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]{ Definition: Definition[facadeDiscoveryLifecycleConfig]{
Defaults: func() facadeDiscoveryLifecycleConfig { Defaults: func() facadeDiscoveryLifecycleConfig {
defaultsCalls++ defaultsCalls++
@@ -237,7 +237,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
resolveCalls++ resolveCalls++
configuration.Resolved = "from:" + configuration.Value 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 return nil
}, },
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ }, Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
@@ -272,7 +272,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
Destination: Keys("items"), Destination: Keys("items"),
PlacementMode: PlacementModeSequenceItem, 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" { if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
t.Fatalf("selected sequence item = %#v, %v", selected, err) t.Fatalf("selected sequence item = %#v, %v", selected, err)
} }
@@ -283,7 +283,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
defaults := func() facadeDiscoveryPlacementConfig { defaults := func() facadeDiscoveryPlacementConfig {
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}} 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}, Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
Destination: options.Destination, Destination: options.Destination,
PlacementMode: options.PlacementMode, PlacementMode: options.PlacementMode,

View File

@@ -0,0 +1,53 @@
package configkit_test
import (
"bytes"
"strings"
"testing"
configkit "go.maximumdirect.net/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

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type equivalentConfig struct { type equivalentConfig struct {
@@ -69,7 +69,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
} }
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} { for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
info, ok := result.Metadata().Lookup(configkit.Keys(field)) 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) t.Errorf("%s input info = %#v, %v", field, info, ok)
} }
} }
@@ -132,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]{ 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"})}}}}, 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" { if err != nil || result.Config.Secret != "new-secret-canary" {
t.Fatalf("secret load = %#v, %v", result, err) t.Fatalf("secret load = %#v, %v", result, err)

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
//go:embed testdata/filesystem //go:embed testdata/filesystem

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit" "go.maximumdirect.net/configkit"
) )
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) { func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
@@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
if phase == "resolution" { if phase == "resolution" {
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items (*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload") resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
return nil return nil
} }
} }
@@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
case "resolution": case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["items"] = items (*value)["items"] = items
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records") resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
return nil return nil
} }
} }
@@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
case "resolution": case "resolution":
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error { options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}} (*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 return nil
} }
} }

View File

@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) { func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
@@ -40,12 +40,16 @@ func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
if len(metadata.History(paths[6])) == 0 { if len(metadata.History(paths[6])) == 0 {
t.Fatal("enumerated numeric path lost history") t.Fatal("enumerated numeric path lost history")
} }
assertPublicShapeHistory(t, metadata, configkit.LayerCLI)
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok { if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
t.Fatal("mapping-key path resolved against the effective sequence") t.Fatal("mapping-key path resolved against the effective sequence")
} }
if len((configkit.Metadata{}).HistoricalPaths()) != 0 { if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
t.Fatal("zero metadata contains historical paths") 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") paths[1] = configkit.Keys("mutated")
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) { if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
@@ -53,6 +57,60 @@ func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
} }
} }
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 { func pathStrings(paths []configkit.Path) []string {
result := make([]string, len(paths)) result := make([]string, len(paths))
for index, path := range paths { for index, path := range paths {

View File

@@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) { func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {

View File

@@ -93,7 +93,8 @@ main: main
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) { func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
type config struct { type config struct {
Value string `yaml:"value"` Value string `yaml:"value"`
Hidden string `yaml:"hidden"`
} }
directory := t.TempDir() directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit: mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
@@ -101,17 +102,23 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
- dir: snippets - dir: snippets
`) `)
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n") 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}) unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
if err != nil || unrestricted.Config.Value != "imported" { if err != nil || unrestricted.Config.Value != "imported" {
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err) 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{}}}) empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
if err != nil || empty.Config.Value != "" { if err != nil || empty.Config.Value != "" {
t.Fatalf("empty pattern = %#v, %v", empty, err) t.Fatalf("empty pattern = %#v, %v", empty, err)
} }
emptySources := empty.Sources() 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) t.Fatalf("empty-pattern evidence = %#v", emptySources)
} }
required := true required := true
@@ -120,7 +127,7 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("required empty selection error = %T %v", err, err) 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) t.Fatalf("required empty selection was recorded as optional: %#v", sources)
} }
} }
@@ -230,15 +237,15 @@ value: loaded
callbackPath := "" callbackPath := ""
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
first, ok := resolution.Source("source-1") 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) 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") 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) t.Fatalf("resolution snapshot changed after mutation: %#v", second)
} }
callbackPath = second.ImportSelections[0].Path.String() callbackPath = second.EmptyImportSelections[0].Path.String()
return nil return nil
}}}) }}})
if err != nil { if err != nil {
@@ -248,11 +255,11 @@ value: loaded
t.Fatalf("callback path = %q", callbackPath) t.Fatalf("callback path = %q", callbackPath)
} }
sources := loaded.Sources() 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) t.Fatalf("result source evidence = %#v", sources)
} }
sources[0].ImportSelections[0].Path = Keys("result mutation") sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("result snapshot changed after mutation: %q", got) t.Fatalf("result snapshot changed after mutation: %q", got)
} }
@@ -268,11 +275,11 @@ value: loaded
t.Fatalf("failed load error = %T %v", err, err) t.Fatalf("failed load error = %T %v", err, err)
} }
errorSources := loadError.Sources() 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) t.Fatalf("error source evidence = %#v", errorSources)
} }
errorSources[0].ImportSelections[0].Path = Keys("error mutation") errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" { if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("error snapshot changed after mutation: %q", got) t.Fatalf("error snapshot changed after mutation: %q", got)
} }
} }

View File

@@ -42,7 +42,7 @@ entries:
Definition: Definition[map[string]any]{ Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")}, SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
Secret: func(candidate Path) bool { Sensitive: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version" return candidate.String() == "/services/records/0/version"
}, },
}, },
@@ -67,6 +67,14 @@ entries:
} }
sources := result.Sources() 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)) filesByID := make(map[string]string, len(sources))
for _, source := range sources { for _, source := range sources {
filesByID[source.ID] = source.Path filesByID[source.ID] = source.Path
@@ -77,8 +85,8 @@ entries:
itemSource = source itemSource = source
} }
} }
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" { if itemSource.VersionEvidence == nil || !itemSource.VersionEvidence.Version.Present || !itemSource.VersionEvidence.Redacted || itemSource.VersionEvidence.Version.Text != "" {
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck) t.Fatalf("consumed header inspection = %#v", itemSource.VersionEvidence)
} }
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id")) 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" { 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]{ Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")}, SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
Secret: func(candidate Path) bool { Sensitive: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version" return candidate.String() == "/services/records/0/version"
}, },
}, },
@@ -129,7 +137,7 @@ entries:
break 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) t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
} }
} }

View File

@@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) { func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type inheritedEntry struct { type inheritedEntry struct {
@@ -42,7 +42,7 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) { if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
t.Error("inherited false is not explicit") 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 { if !found || len(info.Origin.Inheritance) != 1 {
t.Errorf("resolver inherited origin = %#v, %v", info, found) 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]{ result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{ Definition: configkit.Definition[configuration]{
SequenceIdentities: registrations, 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" 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]{ _, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{ Definition: configkit.Definition[configuration]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")}, 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) { if err == nil || strings.Contains(err.Error(), canary) {
@@ -468,7 +468,7 @@ func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
Entries map[string]entry `yaml:"entries"` 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]{ 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 { if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
t.Fatalf("inherited credential = %#v, %v", result, err) t.Fatalf("inherited credential = %#v, %v", result, err)

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) { func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) { func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {

View File

@@ -10,7 +10,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type layeredFacadeConfig struct { type layeredFacadeConfig struct {
@@ -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")) configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
return nil return nil
}, },
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ }, Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" }, Sensitive: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
}, },
}) })
if err != nil { if err != nil {
@@ -80,7 +80,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
if len(sources) != 8 { if len(sources) != 8 {
t.Fatalf("sources = %#v", sources) 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} 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} wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
for index, source := range sources { for index, source := range sources {

View File

@@ -7,7 +7,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type mappingFacadeConfig struct { type mappingFacadeConfig struct {
@@ -36,7 +36,7 @@ func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
if !reflect.DeepEqual(result.Config, want) { if !reflect.DeepEqual(result.Config, want) {
t.Fatalf("mapping config = %#v, want %#v", 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) t.Fatalf("mapping sources = %#v", sources)
} }
info, ok := result.Metadata().Lookup(configkit.Keys("name")) 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 { if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
t.Fatalf("mapping import = %#v, %v", result, err) 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) t.Fatalf("mapping import sources = %#v", sources)
} }
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid) checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)

View File

@@ -0,0 +1,63 @@
package configkit_test
import (
"errors"
"testing"
configkit "go.maximumdirect.net/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

@@ -12,7 +12,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type observedResourceFS struct { type observedResourceFS struct {

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) { func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) { func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
@@ -125,7 +125,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
t.Fatalf("removal not explained: %#v", explanation) t.Fatalf("removal not explained: %#v", explanation)
} }
last := explanation.History[len(explanation.History)-1] 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) 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" { if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
t.Fatalf("ordinary item placement = %#v", ordinary.Config) 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) t.Fatalf("ordinary item source = %#v", sources)
} }
@@ -230,7 +230,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
t.Fatalf("layered item placement = %#v", layered.Config) t.Fatalf("layered item placement = %#v", layered.Config)
} }
for _, source := range layered.Sources() { 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) t.Errorf("layered item source = %#v", source)
} }
} }
@@ -334,7 +334,7 @@ items:
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts) t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
} }
for index, source := range callbackSources { 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) t.Errorf("callback source = %#v", source)
} }
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" { 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) 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) t.Fatalf("final item sources = %#v", got)
} }
} }
@@ -372,9 +372,9 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
t.Fatalf("early failure = %v", err) t.Fatalf("early failure = %v", err)
} }
sources := loadError.Sources() 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[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 { sources[1].PlacementMode != configkit.PlacementModeMapping {
t.Fatalf("early source snapshots = %#v", sources) t.Fatalf("early source snapshots = %#v", sources)
} }
@@ -399,13 +399,13 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
t.Fatalf("post-placement failure = %v", err) t.Fatalf("post-placement failure = %v", err)
} }
sources = loadError.Sources() 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[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved { sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationIsFallback {
t.Fatalf("post-placement source snapshots = %#v", sources) t.Fatalf("post-placement source snapshots = %#v", sources)
} }
firstSnapshot := sources[1] firstSnapshot := sources[1]
sources[1].Destination = configkit.Keys("mutated") 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) t.Fatalf("failure source snapshots were aliased: %#v", again)
} }
@@ -420,7 +420,7 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("successful item load: %v", err) 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) t.Fatalf("callback source snapshot = %#v", callback)
} }
} }
@@ -450,13 +450,13 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
if len(sources) != 3 { if len(sources) != 3 {
t.Fatalf("nested source snapshots = %#v", sources) 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) 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) 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) t.Errorf("nested helper fallback = %#v", got)
} }
} }
@@ -496,7 +496,7 @@ func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) { func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
zero := configkit.SourceInfo{} zero := configkit.SourceInfo{}
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved { if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationIsFallback {
t.Fatalf("zero source info = %#v", zero) t.Fatalf("zero source info = %#v", zero)
} }
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true})) 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) t.Fatal(err)
} }
sources := loaded.Sources() 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) t.Fatalf("mapping source evidence = %#v", sources)
} }
first := sources[0] first := sources[0]
first.Destination = configkit.Keys("caller") 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) 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}}}}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
Definition: configkit.Definition[configuration]{ Definition: configkit.Definition[configuration]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")}, 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" return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
}, },
Resolve: func(value *configuration, resolution *configkit.Resolution) error { Resolve: func(value *configuration, resolution *configkit.Resolution) error {
@@ -611,8 +611,8 @@ entries:
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) { if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
t.Error("resolver treated an omitted field as explicit") t.Error("resolver treated an omitted field as explicit")
} }
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count")) info, found := resolution.EntryInfo(configkit.Keys("entries").Index(0).Key("count"))
if !found || info.Origin.Layer != configkit.LayerBase { if !found || info.Origin.Layer != configkit.LayerMain {
t.Errorf("resolver current-record input info = %#v, %v", info, found) t.Errorf("resolver current-record input info = %#v, %v", info, found)
} }
for _, id := range []string{"source-3", "source-4"} { for _, id := range []string{"source-3", "source-4"} {
@@ -646,7 +646,7 @@ entries:
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts) t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
} }
for _, source := range callbackItems { 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) t.Errorf("callback item source = %#v", source)
} }
} }

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type movedVersionItem struct { type movedVersionItem struct {
@@ -64,7 +64,7 @@ func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
t.Fatal("unrelated underlay payload was treated as the main declaration") t.Fatal("unrelated underlay payload was treated as the main declaration")
} }
for _, source := range loaded.Sources() { 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") t.Fatal("effective movement changed source-local version evidence")
} }
} }

View File

@@ -10,7 +10,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) { func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
@@ -25,7 +25,7 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
definition := configkit.Definition[map[string]any]{ definition := configkit.Definition[map[string]any]{
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}, 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 }}, 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" first := "records: [{id: first, version: first-version}]\n"
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n" child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
@@ -61,7 +61,7 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
} }
} }
sources := result.Sources() 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 { 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) t.Fatalf("source-local evidence changed: %#v", check)
} }
@@ -209,7 +209,7 @@ func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
continue continue
} }
foundMapping = true 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) 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")}, {"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} { 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]{ loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
SourceVersionMode: mode, SourceVersionMode: mode,
Definition: configkit.Definition[map[string]any]{ Definition: configkit.Definition[map[string]any]{
@@ -275,7 +275,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
if !found { if !found {
t.Fatal("missing callback source") t.Fatal("missing callback source")
} }
callbackEvidence = source.VersionCheck callbackEvidence = source.VersionEvidence
return nil return nil
}, },
}, },
@@ -283,7 +283,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"), Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
}}}}, }}}},
}) })
var evidence *configkit.SourceVersionCheck var evidence *configkit.SourceVersionEvidence
if mode == configkit.SourceVersionModeRequired { if mode == configkit.SourceVersionModeRequired {
var failure *configkit.LoadError var failure *configkit.LoadError
if loaded != nil || !errors.As(err, &failure) { 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() { 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) t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
} }
evidence = failure.Sources()[1].VersionCheck evidence = failure.Sources()[1].VersionEvidence
} else { } else {
if err != nil { if err != nil {
t.Fatalf("%s: %v", test.name, err) 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() { if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
t.Errorf("%s: callback path is incomplete", test.name) t.Errorf("%s: callback path is incomplete", test.name)
} }
@@ -365,7 +365,7 @@ func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
if child["value"] != "good" { if child["value"] != "good" {
t.Fatal("valid sibling inheritance was not applied") 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() { if check == nil || check.Version.Present || check.Path.String() != path.String() {
t.Fatal("optional omission evidence was not retained") t.Fatal("optional omission evidence was not retained")
} }
@@ -544,16 +544,16 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
var callbackSource configkit.SourceInfo var callbackSource configkit.SourceInfo
var callbackContext configkit.ResourceContext var callbackContext configkit.ResourceContext
definition := configkit.Definition[map[string]any]{ definition := configkit.Definition[map[string]any]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, 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" }, Sensitive: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
var found bool var found bool
callbackSource, found = resolution.Source("source-1") callbackSource, found = resolution.Source("source-1")
if !found { if !found {
t.Fatal("resolution callback could not query the acquired source") t.Fatal("resolution callback could not query the acquired source")
} }
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" { if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck) t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionEvidence)
} }
callbackContext, found = resolution.SourceContext("source-1") callbackContext, found = resolution.SourceContext("source-1")
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." { if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
@@ -568,17 +568,17 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
if err != nil || loaded == nil { if err != nil || loaded == nil {
t.Fatalf("LoadLayered = %#v, %v", loaded, err) t.Fatalf("LoadLayered = %#v, %v", loaded, err)
} }
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" { if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck) t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionEvidence)
} }
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback") callbackSource.VersionEvidence.Path = configkit.Keys("mutated-callback")
callbackSource.VersionCheck.Version.Text = canary callbackSource.VersionEvidence.Version.Text = canary
callbackSource.VersionCheck.Location.Line = 98 callbackSource.VersionEvidence.Location.Line = 98
finalSources := loaded.Sources() 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) 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 || 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 { check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
t.Fatalf("final classified evidence = %#v", check) t.Fatalf("final classified evidence = %#v", check)
@@ -594,9 +594,9 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
check.Redacted = false check.Redacted = false
check.Version.Text = "mutated" check.Version.Text = "mutated"
repeated := loaded.Sources() repeated := loaded.Sources()
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" || if len(repeated) != 1 || repeated[0].VersionEvidence.Path.String() != "/version" ||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted || repeated[0].VersionEvidence.Location.Line != 1 || !repeated[0].VersionEvidence.Redacted ||
repeated[0].VersionCheck.Version.Text != "" { repeated[0].VersionEvidence.Version.Text != "" {
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated) 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) 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) t.Fatalf("unclassified evidence = %#v", got)
} }
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" { if publicCallback.VersionEvidence == nil || !publicCallback.VersionEvidence.Redacted || publicCallback.VersionEvidence.Version.Text != "" {
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck) t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionEvidence)
} }
} }
@@ -650,7 +650,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
if checked != 1 { if checked != 1 {
t.Fatalf("main declaration checker calls = %d; error %v", checked, err) 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 != "" { 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()) 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) t.Fatalf("malformed version error = %v", err)
} }
sources := loadError.Sources() sources := loadError.Sources()
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted || if len(sources) != 1 || sources[0].VersionEvidence == nil || !sources[0].VersionEvidence.Redacted ||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" { !sources[0].VersionEvidence.Version.Present || sources[0].VersionEvidence.Version.Text != "" || sources[0].VersionEvidence.Path.String() != "/version" {
t.Fatalf("failed-load source evidence = %#v", sources) t.Fatalf("failed-load source evidence = %#v", sources)
} }
sources[0].VersionCheck.Path = configkit.Keys("mutated-error") sources[0].VersionEvidence.Path = configkit.Keys("mutated-error")
sources[0].VersionCheck.Version.Text = "mutated" sources[0].VersionEvidence.Version.Text = "mutated"
sources[0].VersionCheck.Location.Line = 99 sources[0].VersionEvidence.Location.Line = 99
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.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) t.Fatalf("LoadError source evidence was aliased: %#v", again)
} }
for _, test := range []struct { for _, test := range []struct {
@@ -707,7 +707,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
if strings.Contains(err.Error(), test.canary) { if strings.Contains(err.Error(), test.canary) {
t.Fatalf("late failure disclosed source-version canary: %v", err) 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 != "" { if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check) 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]{}) 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) t.Fatalf("default source version check = %#v, %v", defaultResult, err)
} }
@@ -728,7 +728,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
} }
sawCLI := false sawCLI := false
for _, source := range withCLI.Sources() { 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) t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
} }
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI 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) t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
} }
sources := loaded.Sources() 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) 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 { if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
t.Fatalf("optional omission evidence = %#v", absent) 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" { if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
t.Fatalf("null declaration evidence = %#v", null) t.Fatalf("null declaration evidence = %#v", null)
} }
@@ -807,7 +807,7 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
}, },
Resolve: func(current *configuration, resolution *configkit.Resolution) error { Resolve: func(current *configuration, resolution *configkit.Resolution) error {
current.Service.Resolved = "resolved once" 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 return nil
}, },
Validate: func(current *configuration, validation *configkit.Validation) { 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()) t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
} }
sourcesA, sourcesB := first.Sources(), second.Sources() sourcesA, sourcesB := first.Sources(), second.Sources()
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil || if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionEvidence == nil || sourcesB[1].VersionEvidence == nil ||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" { 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) t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
} }
versionExplanation, found := first.Explain(configkit.Keys("service", "version")) versionExplanation, found := first.Explain(configkit.Keys("service", "version"))

View File

@@ -0,0 +1,50 @@
package configkit_test
import (
"testing"
configkit "go.maximumdirect.net/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

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
func TestUnderlaySequenceDescendantOrigins(t *testing.T) { func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
@@ -31,7 +31,7 @@ func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
for _, pointer := range paths { for _, pointer := range paths {
path, _ := configkit.ParsePath(pointer) path, _ := configkit.ParsePath(pointer)
info, ok := resolution.InputInfo(path) info, ok := resolution.EntryInfo(path)
if !ok { if !ok {
t.Errorf("missing resolver input %s", pointer) t.Errorf("missing resolver input %s", pointer)
} }

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "go.maximumdirect.net/configkit"
) )
type versionCountingReader struct { type versionCountingReader struct {

2
go.mod
View File

@@ -1,4 +1,4 @@
module gitea.maximumdirect.net/eric/ConfigKit module go.maximumdirect.net/configkit
go 1.26.0 go 1.26.0

View File

@@ -44,10 +44,10 @@ func TestDependencyImportBoundary(t *testing.T) {
production := !strings.HasSuffix(path, "_test.go") production := !strings.HasSuffix(path, "_test.go")
rootProduction := production && filepath.Dir(path) == "." rootProduction := production && filepath.Dir(path) == "."
internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/") internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/")
if rootProduction && strings.HasPrefix(importPath, "gitea.maximumdirect.net/eric/ConfigKit/internal/") && importPath != "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" { if rootProduction && strings.HasPrefix(importPath, "go.maximumdirect.net/configkit/internal/") && importPath != "go.maximumdirect.net/configkit/internal/engine" {
t.Errorf("%s bypasses the engine boundary through %s", path, importPath) t.Errorf("%s bypasses the engine boundary through %s", path, importPath)
} }
if internalProduction && importPath == "gitea.maximumdirect.net/eric/ConfigKit" { if internalProduction && importPath == "go.maximumdirect.net/configkit" {
t.Errorf("%s imports the public root package", path) t.Errorf("%s imports the public root package", path)
} }
} }

View File

@@ -3,18 +3,21 @@ package configkit
import ( import (
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine" "go.maximumdirect.net/configkit/internal/engine"
) )
// ValueSnapshot is a detached, deterministic YAML representation of one value. // ValueSnapshot describes a saved value as consistently formatted YAML text.
// Present distinguishes absence or deletion from an explicit null value. // It is a copy; changing it cannot affect later inspection. Present is false for
// absence or deletion, while explicit null is present. Redacted reports whether
// the value or a descendant was hidden.
type ValueSnapshot struct { type ValueSnapshot struct {
Present bool Present bool
Redacted bool Redacted bool
YAML string YAML string
} }
// ValueChange describes one explicit definition or audited domain operation. // ValueChange shows values before and after an input contribution or recorded
// application operation, together with its origin, reasons, and dependencies.
type ValueChange struct { type ValueChange struct {
Before ValueSnapshot Before ValueSnapshot
After ValueSnapshot After ValueSnapshot
@@ -29,7 +32,8 @@ type SecretReferenceInfo struct {
Name string Name string
} }
// Explanation is a detached safe account of one effective or historical path. // Explanation describes a path's final value and earlier contributions, with
// sensitive values hidden. It is a copy that cannot alter later inspection.
type Explanation struct { type Explanation struct {
Path Path Path Path
Effective ValueSnapshot Effective ValueSnapshot
@@ -38,9 +42,10 @@ type Explanation struct {
SecretReference *SecretReferenceInfo SecretReference *SecretReferenceInfo
} }
// Explain returns safe effective and chronological values for a known final or // Explain returns the saved final value and earlier values for a known current
// historical path. Structural inheritance appears between source applications // or historical path, hiding sensitive information. History follows processing
// and typed reconciliation. Unknown and malformed paths return false. // order: inheritance appears after source combination and before the transition
// to typed values. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) { func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil { if r == nil {
return Explanation{}, false return Explanation{}, false
@@ -49,8 +54,9 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
return fromEngineExplanation(value), ok return fromEngineExplanation(value), ok
} }
// WriteEffective writes deterministic redacted YAML from the frozen resolved // WriteEffective writes consistently formatted YAML for the saved final
// snapshot. The output is intended for inspection and is not promised reloadable. // configuration, with sensitive values hidden. Later changes to Result.Config
// do not change the output. It is for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output. // Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error { func (r *Result[T]) WriteEffective(output io.Writer) error {
if r == nil { if r == nil {
@@ -61,7 +67,7 @@ func (r *Result[T]) WriteEffective(output io.Writer) error {
} }
// WriteExplanation writes a deterministic human-readable rendering of Explain. // WriteExplanation writes a deterministic human-readable rendering of Explain.
// Unknown paths produce no output. Writer failures may leave partial output. // Unknown paths return an error without output. Writer failures may leave partial output.
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error { func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
if r == nil { if r == nil {
var inspection *engine.Inspection var inspection *engine.Inspection

View File

@@ -4,7 +4,7 @@ import (
"strconv" "strconv"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func BenchmarkComposeWideMappings(b *testing.B) { func BenchmarkComposeWideMappings(b *testing.B) {

View File

@@ -20,9 +20,9 @@ func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error { definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
Normalize(r, Keys("map"), &c.Map, shared, "populate") Normalize(r, Keys("map"), &c.Map, shared, "populate")
c.Pointer = external c.Pointer = external
r.Record(Keys("pointer"), ChangeNormalization, "populate") r.RecordNormalization(Keys("pointer"), "populate")
c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET") c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET")
r.Record(Keys("secret"), ChangeNormalization, "reference") r.RecordNormalization(Keys("secret"), "reference")
return nil return nil
}} }}
if validatorAssignment { if validatorAssignment {

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) { func TestOverlayBroadCollectionPoliciesAndRetainedDeclarations(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"sort" "sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type collectionPolicyScope struct { type collectionPolicyScope struct {

View File

@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) { func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) {

View File

@@ -1,6 +1,6 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "go.maximumdirect.net/configkit/internal/document"
// A true entry replaces the collection itself as well as its descendants; // A true entry replaces the collection itself as well as its descendants;
// false records ordinary ancestor replacement without selecting a collection // false records ordinary ancestor replacement without selecting a collection

View File

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) { func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"maps" "maps"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
// composedDocument is the private handoff between source assembly and later // composedDocument is the private handoff between source assembly and later
@@ -223,7 +223,7 @@ func composeDocumentsWithCollectionPolicy(
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned) rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
} }
for sourceIndex, input := range inputs { for sourceIndex, input := range inputs {
application := historyApplication{layer: LayerBase, value: input} application := historyApplication{layer: LayerMain, value: input}
for _, assigned := range rangesBySource[sourceIndex] { for _, assigned := range rangesBySource[sourceIndex] {
pointer := assigned.path.String() pointer := assigned.path.String()
mode := sequenceHistoryAppend mode := sequenceHistoryAppend
@@ -255,7 +255,7 @@ func composeDocumentsWithCollectionPolicy(
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) { func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
pointer := path.String() pointer := path.String()
if _, present := origins[pointer]; !present { 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 { switch node.Kind {
case document.Mapping: case document.Mapping:
@@ -513,7 +513,7 @@ func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange
walkHistoryApplication(application, func(_, effective Path, node *document.Node) { walkHistoryApplication(application, func(_, effective Path, node *document.Node) {
pointer := effective.String() pointer := effective.String()
origin := origins[pointer] origin := origins[pointer]
origin.Layer = LayerBase origin.Layer = LayerMain
origin.Locations = append(origin.Locations, publicLocation(node.Location)) origin.Locations = append(origin.Locations, publicLocation(node.Location))
origins[pointer] = origin origins[pointer] = origin
}) })

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) { func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
@@ -92,7 +92,7 @@ values: [one, two]
{SourceID: "source-1", Line: 2, Column: 3}, {SourceID: "source-1", Line: 2, Column: 3},
{SourceID: "source-2", 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) 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"} wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"}

View File

@@ -5,8 +5,8 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" yamladapter "go.maximumdirect.net/configkit/internal/yaml"
) )
func FuzzCompositionAndOverlay(f *testing.F) { func FuzzCompositionAndOverlay(f *testing.F) {

View File

@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"time" "time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
@@ -448,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) { func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
if input, ok := presence[path.String()]; ok { 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 { } else {
origins[path.String()] = Origin{Layer: LayerDefault} origins[path.String()] = Origin{Layer: LayerDefault}
} }

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type namedSmall int8 type namedSmall int8
@@ -141,7 +141,7 @@ custom: accepted
if _, ok := decoded.presence[Keys("nested", "a").String()]; ok { if _, ok := decoded.presence[Keys("nested", "a").String()]; ok {
t.Fatal("parent mapping made omitted child explicit") 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) t.Fatalf("origins = %#v", decoded.origins)
} }
if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 { if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 {

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"errors" "errors"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
var ( var (

View File

@@ -6,7 +6,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) { func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {

View File

@@ -70,7 +70,7 @@ func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T)
child := config.Catalog.Entries["child"] child := config.Catalog.Entries["child"]
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height) child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
config.Catalog.Entries["child"] = child 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 return nil
}, },
Validate: func(config *configuration, validation *Validation) { Validate: func(config *configuration, validation *Validation) {

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type importDestinationConfig struct { type importDestinationConfig struct {

View File

@@ -287,7 +287,7 @@ func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testi
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error { Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
observed.resolve++ observed.resolve++
configuration.Tenant.Derived = configuration.Tenant.Dynamic 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 return nil
}, },
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) { Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
@@ -425,7 +425,7 @@ func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
definition: Definition[config]{ definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
configuration.Tenant.Derived = configuration.Tenant.Dynamic 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 return nil
}, },
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" }, Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },

View File

@@ -5,7 +5,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) { func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {

View File

@@ -50,6 +50,16 @@ func (e *LoadError) Sources() []SourceInfo {
return cloneSourceInfos(e.sources) 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 // WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes. // control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
@@ -88,7 +98,9 @@ func (a *diagnosticAccumulator) loadError() *LoadError {
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic { func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics) canonical := cloneDiagnostics(diagnostics)
for i := range canonical { for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation) if canonical[i].Code != CodeComposeConflict {
slices.SortFunc(canonical[i].Related, compareLocation)
}
} }
type orderedDiagnostic struct { type orderedDiagnostic struct {
diagnostic Diagnostic diagnostic Diagnostic

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"io" "io"
"slices"
"strings" "strings"
"testing" "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) { func TestWriteDiagnosticsEscapesControls(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -5,8 +5,8 @@ import (
"errors" "errors"
"io" "io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml" yamladapter "go.maximumdirect.net/configkit/internal/yaml"
) )
var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable") var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable")
@@ -34,11 +34,11 @@ func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
deltas := compareConfigurationValues(left.final, right.final) deltas := compareConfigurationValues(left.final, right.final)
regions := make([]diffRegion, 0, len(deltas)) regions := make([]diffRegion, 0, len(deltas))
for _, delta := range 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 { if !leftPresent {
leftNode = nil leftNode = nil
} }
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup) rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.documentLookup())
if !rightPresent { if !rightPresent {
rightNode = nil rightNode = nil
} }

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestPreparePairedDiffSnapshotReportsWhollyMaskedChanges(t *testing.T) { func TestPreparePairedDiffSnapshotReportsWhollyMaskedChanges(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type diffIntegrationConfig struct { type diffIntegrationConfig struct {
@@ -29,7 +29,7 @@ func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error { Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
resolveCalls++ resolveCalls++
configuration.Derived = configuration.Dynamic["visible"].(string) 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 return nil
}, },
}} }}
@@ -53,7 +53,7 @@ func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
!right.Inspection.secrets["/credential"] || !right.Inspection.secrets["/derived"] { !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) 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) 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 { if err != nil || len(differences) != 0 {
t.Fatalf("credential rotation behind one reference = %#v, err = %v", differences, err) 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) 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) t.Fatalf("right frozen reference = %#v, present=%v", node, present)
} }
@@ -164,7 +164,7 @@ func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
if absent.Config.Optional != nil { if absent.Config.Optional != nil {
t.Fatalf("missing optional reference decoded as %#v", absent.Config.Optional) 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) t.Fatalf("optional nil projection = %#v, present=%v", node, present)
} }
differences, err = Diff(absent.Inspection, left.Inspection) 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) { t.Run("removed historical value", func(t *testing.T) {
left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error { left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
delete(configuration.Dynamic, "token") delete(configuration.Dynamic, "token")
resolution.Record(secretPath, ChangeNormalization, "remove token") resolution.RecordNormalization(secretPath, "remove token")
return nil return nil
}) })
right := load("dynamic: {token: current-canary}\n", nil) right := load("dynamic: {token: current-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] { if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("removed historical selector was not retained") 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") t.Fatal("removed token remains in the effective left tree")
} }
differences, err := Diff(left.Inspection, right.Inspection) 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) { t.Run("replaced historical value", func(t *testing.T) {
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error { left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
configuration.Dynamic["token"] = "replacement-canary" configuration.Dynamic["token"] = "replacement-canary"
resolution.Record(secretPath, ChangeNormalization, "replace token") resolution.RecordNormalization(secretPath, "replace token")
return nil return nil
}) })
right := load("dynamic: {token: other-canary}\n", nil) right := load("dynamic: {token: other-canary}\n", nil)

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"sort" "sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
type pairedDiffSnapshot struct { type pairedDiffSnapshot struct {

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestPreparePairedDiffSnapshotRedactsAlignedMappingFromEitherSide(t *testing.T) { func TestPreparePairedDiffSnapshotRedactsAlignedMappingFromEitherSide(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package engine
import ( import (
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
func TestPrepareDiffRegionsUsesCanonicalConfigurationComparison(t *testing.T) { func TestPrepareDiffRegionsUsesCanonicalConfigurationComparison(t *testing.T) {
@@ -127,7 +127,10 @@ type expectedDiffRegion struct {
} }
func diffTestInspection(root *document.Node) *Inspection { 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) { func assertDiffRegionNode(t *testing.T, side string, node *document.Node, wantPresent bool, wantKind document.Kind) {

View File

@@ -4,7 +4,7 @@ import (
"path" "path"
"strings" "strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
) )
const directiveKey = "configkit" const directiveKey = "configkit"

View File

@@ -481,7 +481,7 @@ func resolveSearchInput(source Source, sourceID string, helpers discoveryDirecto
if len(diagnostics) == 0 || selected.kind != sourceFile { if len(diagnostics) == 0 || selected.kind != sourceFile {
return selected, nil, diagnostics 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 { for index := range diagnostics {
diagnostics[index].Primary = &Location{SourceID: sourceID} diagnostics[index].Primary = &Location{SourceID: sourceID}
} }

View File

@@ -77,7 +77,7 @@ settings: {underlay: value}
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported")) Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
sources := searched.Inspection.Sources() 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) t.Fatalf("layered search source evidence = %#v", sources)
} }
} }

View File

@@ -2,7 +2,7 @@ package engine
import ( import (
"cmp" "cmp"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "go.maximumdirect.net/configkit/internal/document"
"slices" "slices"
) )

View File

@@ -1,6 +1,6 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "go.maximumdirect.net/configkit/internal/document"
// documentLookup contains read-only mapping positions for a completed document. // documentLookup contains read-only mapping positions for a completed document.
type documentLookup struct { type documentLookup struct {

Some files were not shown because too many files have changed in this diff Show More