63 Commits

Author SHA1 Message Date
f9ec66e867 Adopt shared history ownership in the interface roadmap 2026-10-26 12:36:17 +00:00
be5cdeb502 Plan interface contract remediation 2026-10-26 02:40:21 +00:00
1f64673d0d Record API contract audit findings 2026-10-26 01:36:20 +00:00
01b08e76a3 Retire the completed consolidated audit 2026-10-26 01:25:35 +00:00
453da684d7 Write release notes for v0.10.2 2026-10-26 01:19:38 +00:00
fb6c254d38 Mark import simplification roadmap complete 2026-10-26 00:21:57 +00:00
a71413ce67 Document import and facade boundaries 2026-10-26 00:21:40 +00:00
d66338a1c5 Construct placement directly across the facade 2026-10-26 00:18:12 +00:00
643f43ce6a Snapshot import defaults at load preflight 2026-10-26 00:13:34 +00:00
c25cf39d19 Consolidate engine import defaults 2026-10-26 00:10:07 +00:00
7d18c93339 Extract patterned glob entry preparation 2026-10-26 00:08:23 +00:00
b1d89810ca Plan discovery and facade boundary refactoring 2026-10-26 00:03:02 +00:00
bb5540b62e Plan import discovery and facade simplification 2026-10-25 23:55:45 +00:00
80cf23e710 Mark the implementation roadmap complete 2026-10-25 23:26:29 +00:00
ac48557535 Document and verify the remediation sprint 2026-10-25 23:26:10 +00:00
b4af900c10 Index secret failure locations lazily 2026-10-25 23:21:29 +00:00
f132cd6468 Index inherited compound identity restoration 2026-10-25 23:17:59 +00:00
75e209eb3b Index tier history sources lazily 2026-10-25 23:13:00 +00:00
1dd94a3cc6 Share sequence history construction 2026-10-25 23:09:41 +00:00
d172a5e46a Apply sparse collection policy across overlays 2026-10-25 23:05:23 +00:00
3a1b6ff355 Borrow lower collection policies during strict composition 2026-10-25 22:59:03 +00:00
68383f34de Index collection policy retirement 2026-10-25 22:49:02 +00:00
549fca239d Separate collection overlay responsibilities 2026-10-25 22:42:21 +00:00
883244936c Share layered tier application 2026-10-25 22:37:46 +00:00
a8ae2e9d5e Clarify load context and inheritance handoffs 2026-10-25 22:34:52 +00:00
f18582106b Isolate standalone input completion 2026-10-25 22:32:52 +00:00
d8bf6abc2b Isolate source directive processing 2026-10-25 22:30:46 +00:00
4bf940c579 Use deferred acquisition consistently 2026-10-25 22:28:35 +00:00
4fd3bee986 Strengthen composition and import selection tests 2026-10-25 22:21:51 +00:00
6f6130fd12 Use production directive extraction in import tests 2026-10-25 22:19:38 +00:00
3122e2f32d Remove unread private transport members 2026-10-25 22:18:12 +00:00
e4a5ecb4be Remove disconnected facade clone scaffolding 2026-10-25 22:13:00 +00:00
826062aa17 Test public historical path enumeration 2026-10-25 22:09:55 +00:00
08f2a8f3e9 Cache approved dynamic descriptors per operation 2026-10-25 22:07:37 +00:00
12d350a292 Enforce the retained history node limit 2026-10-25 22:04:54 +00:00
4fcff5f453 Prepare inspection history before publication 2026-10-25 21:53:24 +00:00
247a12bc50 Remove unused combination evidence and helpers 2026-10-25 21:49:52 +00:00
119b42c810 Remove unused history document copies 2026-10-25 21:47:53 +00:00
0f9ecf12ed Share link dependency evaluation with host access 2026-10-25 21:45:33 +00:00
7bf2f0c687 Memoize completed native link dependencies 2026-10-25 21:43:26 +00:00
8aba43da38 Preserve directory traversal failure phases 2026-10-25 21:33:36 +00:00
877c497325 Close owned import roots during panic unwinding 2026-10-25 21:27:47 +00:00
08a8c25542 Close callback contexts when callbacks panic 2026-10-25 21:25:57 +00:00
ea27f17f5c Check projection budgets before container allocation 2026-10-25 21:25:00 +00:00
ae3408a096 Distinguish finite slice views from cycles 2026-10-25 21:22:01 +00:00
deb9aa5c69 Roll back failed dynamic descriptor builds 2026-10-25 21:20:41 +00:00
2736ddfbf2 Terminate pointer-only descriptor traversal 2026-10-25 21:18:35 +00:00
7889ebddfb Plan the audit remediation sprint 2026-10-25 20:52:11 +00:00
4a5316b7fc Describe the audit remediation target and dependencies 2026-10-25 20:25:18 +00:00
e1e94eeec7 Settle audit refactoring scope and defer follow-up work 2026-10-25 20:17:47 +00:00
176e9a5d90 Select localized audit efficiency improvements 2026-10-25 20:13:57 +00:00
23c1976ef1 Settle the initial audit remediation scope 2026-10-25 20:12:12 +00:00
aab79d9508 Consolidate repository audit findings for review 2026-10-25 20:07:46 +00:00
ecd5d5d460 Record dead code and obsolete functionality audit 2026-10-25 17:22:25 +00:00
b1c278b38b Record test coverage and effectiveness audit findings 2026-10-25 17:07:39 +00:00
eb5a82f1f3 Record security audit findings 2026-10-25 16:53:57 +00:00
b30c9f167d Record error handling audit findings 2026-10-25 16:44:08 +00:00
5925066d35 Record resource management audit findings 2026-10-25 16:37:05 +00:00
0cca633a67 Record concurrency and lifetime audit results 2026-10-25 16:25:10 +00:00
a80273433e Record maintainability and complexity audit findings 2026-10-25 16:18:07 +00:00
320d0c5f43 Record algorithmic efficiency audit findings 2026-10-25 16:05:49 +00:00
5098b56655 Record functional correctness audit findings 2026-10-25 15:52:43 +00:00
47944d22d1 Update ReleaseKit to v0.4.2 2026-10-25 15:37:32 +00:00
107 changed files with 5084 additions and 1437 deletions

View File

@@ -81,31 +81,14 @@ 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: cloneImportDefaultBool(value.Required), Required: value.Required,
Follow: cloneImportDefaultBool(value.Follow), Follow: value.Follow,
Hidden: cloneImportDefaultBool(value.Hidden), Hidden: value.Hidden,
Recursive: cloneImportDefaultBool(value.Recursive), Recursive: value.Recursive,
Pattern: cloneImportDefaultStrings(value.Pattern), Pattern: value.Pattern,
} }
} }
func cloneImportDefaultBool(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneImportDefaultStrings(values []string) []string {
if values == nil {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil { if value == nil {
return nil return nil

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) and relevant ADRs listed below | | Architecture decisions or future work | [Future features](roadmap/future.md), [remediation roadmap](roadmap/roadmap.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) |
@@ -113,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
``` ```
Remaining optimization candidates belong to Remaining optimization candidates belong to
[future performance experiments](roadmap/future.md#3-performance-experiments). [future performance experiments](roadmap/future.md#5-performance-experiments).
The completed effective-configuration diff roadmap, implementation plan, and The completed effective-configuration diff roadmap, implementation plan, and
resolved review findings are preserved in Git history at `1c2610c` under resolved review findings are preserved in Git history at `1c2610c` under
@@ -199,6 +199,15 @@ 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 completed audit remediation's scope is recorded in the
[remediation roadmap](roadmap/roadmap.md). 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

View File

@@ -33,6 +33,26 @@ sandbox guarantee. `source_context.go` derives passive resource contexts from
resolved sources for later caller-owned reads; those records do not retain live resolved sources for later caller-owned reads; those records do not retain live
acquisition handles. acquisition handles.
Host and native link bindings share one iterative link-frame evaluator. It
tracks active links for cycle detection and reuses successfully completed link
targets while resolving the remaining path components; each binding retains its
own namespace and target-containment rules. Directory reads distinguish an
initial-open failure from failures after opening: only an initial missing
directory can be treated as optional, while enumeration and close failures
remain selection errors, including errors from the standard `fs.ReadDir`
fallback.
Glob selection keeps traversal coordination in `selectGlobImportCandidates`:
it advances literal and patterned segments, reads directories, unions and sorts
candidates, validates final targets, then applies exclusions and requiredness.
For each patterned directory entry, `prepareGlobEntry` handles matching and
hidden filtering, containment, target and link inspection, intermediate-directory
eligibility, host/native access-path selection, and candidate identity and label.
It returns a candidate, an ordinary skip, or a diagnostic without mutating the
selector's candidate set or traversal state. Directory entry preparation remains
separate because its matching, absence, recursion, and traversal-state rules
differ.
Acquisition remains authored: declarations are visited in source order and each Acquisition remains authored: declarations are visited in source order and each
selected candidate set in namespace-path lexical order. This preserves source selected candidate set in namespace-path lexical order. This preserves source
records, IDs, source-local version checks, and callback ordering. The acquired records, IDs, source-local version checks, and callback ordering. The acquired

View File

@@ -20,14 +20,19 @@ Implemented component owners:
inheritance and decoding, before application resolution. The engine also owns inheritance and decoding, before application resolution. The engine also owns
shared source-version checking with sanitized declaration evidence. shared source-version checking with sanitized declaration evidence.
Explicit import discovery supports declared paths, directory searches, and Explicit import discovery supports declared paths, directory searches, and
globs; load-wide defaults and source-local empty-selection evidence cross the globs; root-owned defaults are converted to the engine's single defaults input
root facade without changing acquisition order. Import and inheritance and snapshotted during load preflight before acquisition, while source-local
resolution share dependency-ready scheduling within their composition scopes. empty-selection evidence crosses the facade without changing acquisition
order. The root constructs placement directly on engine options and inputs
using explicit enum conversion; public types and fields remain root-owned.
Import and inheritance resolution share dependency-ready scheduling within
their composition scopes.
It retains each source's effective placement alongside physical source It retains each source's effective placement alongside physical source
identity and owns schema identity and owns schema
inspection, lifecycle coordination, callback auditing, inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and provenance/history, passive source-context retention, secret resolution and
bootstrap, safe inspection rendering, and effective-value comparison over bootstrap, operation-local secret failure-location attribution, safe
inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package. frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records, - Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
@@ -90,8 +95,14 @@ bounded algorithms.
## Implementation flow ## Implementation flow
The root facade converts caller-owned values into engine-owned requests. The The root facade converts caller-owned values into engine-owned requests. Its
ordinary and grouped entry points share a normalized `loadRequest`: see `ImportDefaults` conversion maps fields without taking ownership; engine load
preflight snapshots the defaults once before source acquisition or callbacks.
Ordinary and grouped loading then carry that read-only value through their
normalized `loadRequest` and acquisition policy, while declaration resolution
copies values needed by its independently owned effective policy. Placement is
constructed directly on the engine's options and input records after explicit
root-to-engine enum conversion. See
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and [`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition, [`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
version, default-import, and override policy stays distinct from each input's version, default-import, and override policy stays distinct from each input's
@@ -101,7 +112,10 @@ source, filesystem, destination, and import settings.
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
input is acquired under that policy; grouped requests retain acquired underlay input is acquired under that policy; grouped requests retain acquired underlay
scopes until their tier is composed in scopes until their tier is composed in
[`load_request.go`](../../internal/engine/load_request.go). Live [`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
share one assembly handoff that applies collection declarations and projects
only that tier's source/version evidence; the caller keeps main-version
protection and its projection explicit. Live
filesystem access handles are confined to acquisition and closed there. Resolved filesystem access handles are confined to acquisition and closed there. Resolved
filesystem facts used to authorize access are separate from passively retained filesystem facts used to authorize access are separate from passively retained
resource contexts. Non-import validation retains those facts on `assembledInput` resource contexts. Non-import validation retains those facts on `assembledInput`

View File

@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
"$releasekit_bin" version "$releasekit_bin" version
``` ```
The module-selected executable is ReleaseKit v0.4.1. Use the built executable The module-selected executable is ReleaseKit v0.4.2. Use the built executable
for publication to preserve its exit status and publication-state diagnostics. for publication to preserve its exit status and publication-state diagnostics.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands `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

70
docs/releases/v0.10.2.md Normal file
View File

@@ -0,0 +1,70 @@
# ConfigKit v0.10.2
This patch release fixes edge-case loading failures, resource cleanup, and limit
accounting, and simplifies ConfigKit's internal loading machinery. Public APIs
and documented configuration semantics remain unchanged from v0.10.1.
## Fixes
- Terminate pointer-only recursive type traversal safely during decoding and
schema-path resolution, preserving nullable values and supported recursive
structures. Roll back failed dynamic descriptor construction so subsequent
operations do not reuse incomplete state or panic.
- Accept finite, non-cyclic slice views that share backing storage while
retaining cycle detection and detached output.
- Deactivate resolution, validation, and version-check contexts when callbacks
panic, and close library-owned import roots during panic unwinding. The
original application panic continues to propagate.
- Report directory enumeration and close failures instead of treating them as
optional absence, including failures reported as not-exist after a successful
open. Genuinely missing optional directories retain their existing behavior.
- Reuse completed symlink dependencies during host and native import discovery,
avoiding exponential repeated expansion of finite link graphs while retaining
cycle detection and containment checks.
- Check existing projection budgets before bulk container allocation and account
for the complete retained inspection-history graph, including replay and
separately copied inheritance evidence.
- Apply the documented append/upsert sequence requirements to non-sequence
contributions at retained collection-policy destinations.
## Internal improvements
- Index collection-policy retirement and prepare sparse incoming policy changes
instead of repeatedly scanning or copying unrelated retained policy.
- Reuse successful dynamic-type eligibility checks within an operation and index
tier-history lookups, compound identity restoration, and secret-failure source
attribution to avoid repeated scans.
- Remove unused snapshots, evidence, helpers, fields, and test-only production
paths. Clarify acquisition handoffs and separate collection preparation,
application, and finalization while sharing sequence-history construction.
- Separate patterned glob entry preparation from traversal. Consolidate engine
import defaults, snapshot them during load preflight, and construct placement
directly across the facade while keeping public types root-owned.
- Add regression coverage for the corrected failures, public historical paths,
defaults snapshots, and resource ownership. Strengthen fuzz detachment checks
and replace redundant or implementation-coupled assertions.
- Update release tooling from ReleaseKit v0.4.1 to v0.4.2.
These changes remove redundant work and improve scaling in the identified paths;
no end-to-end performance improvement has been quantified.
## Compatibility and upgrade
No consumer API or configuration-format migration is required. The
[configuration contract](../config.md) remains authoritative.
Loads that previously succeeded despite directory acquisition failures, invalid
append/upsert contributions, or incomplete retained-history accounting may now
return the documented structured errors. These corrections enforce existing
requirements and limits rather than introducing new ones.
Select this version and rebuild:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.2
```
The consumer minimum remains Go 1.26.0. The YAML dependency and platform support
are unchanged. Publication follows the existing [release procedure](../release.md).
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/docs/oss/attribution.md)

View File

@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
successful-tag-push completion point remain unchanged; CI is not a gate. successful-tag-push completion point remain unchanged; CI is not a gate.
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the - Adopt release policy 1.6, distinguishing the consumer Go minimum from the
validation toolchain. Retire completed planning and measurement records into validation toolchain. Retire completed planning and measurement records into
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments). Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
## Compatibility and upgrade ## Compatibility and upgrade

View File

@@ -1,12 +1,20 @@
# Potential Future Features # Potential Future Work
This document lists potential enhancements for consideration in future ConfigKit This document lists potential enhancements and maintenance work for consideration
releases. Entries are not accepted contracts, implementation plans, or release in future ConfigKit releases. Entries are not accepted contracts, implementation
commitments. Adopting a candidate requires an explicit design decision and a plans, or release commitments. Adopting a candidate requires an explicit design
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. Recursive body merging for identified records ## 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
Status: deferred. The implemented Status: deferred. The implemented
[collection policy](../config.md#strict-document-composition) uses `upsert` to [collection policy](../config.md#strict-document-composition) uses `upsert` to
@@ -20,7 +28,7 @@ presence and ordering, and how provenance, history, sensitivity, and resource
context follow partially retained records. Any change requires a new decision and context follow partially retained records. Any change requires a new decision and
implementation plan; this entry does not alter current upsert behavior. implementation plan; this entry does not alter current upsert behavior.
## 2. Required version declarations for selected sources ## 4. Required version declarations for selected sources
Status: deferred. PromptKit requested a way to require version declarations on Status: deferred. PromptKit requested a way to require version declarations on
a selected subset of source files while permitting other fragments to omit them. a selected subset of source files while permitting other fragments to omit them.
@@ -56,7 +64,7 @@ compatible version or replace final configuration validation.
behavior for absent optional inputs and preserve source-local diagnostics, behavior for absent optional inputs and preserve source-local diagnostics,
inspection evidence, and existing sensitivity handling. inspection evidence, and existing sensitivity handling.
## 3. Performance experiments ## 5. Performance experiments
Status: deferred pending evidence. The completed performance audit identified Status: deferred pending evidence. The completed performance audit identified
four internal optimization candidates for separate investigation: four internal optimization candidates for separate investigation:

View File

@@ -0,0 +1,296 @@
# 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

@@ -0,0 +1,346 @@
# Interface Contract Remediation Roadmap
Status: target settled, including shared history ownership; implementation has
not begun.
Planning baseline: `1f64673` (v0.10.2 implementation plus documentation changes).
This roadmap covers the four findings in the [interface audit](interfaces.md).
The audit owns reproductions, evidence, and finding numbers. This document owns
the combined target, required changes, and general dependency order. A subsequent
implementation plan should own concrete stages, private implementation choices,
validation commands, and progress.
## Purpose And Scope
Restore agreement between ConfigKit's presence, metadata, history, and diagnostic
interfaces under their existing contracts. The first three findings concern
semantic paths that share a JSON Pointer spelling; the fourth concerns a
composition claimant order that is lost during diagnostic canonicalization.
Build one shared internal history model from which metadata history and
explanation are derived. It will own the historical evidence and semantic
attribution used by both views, replacing their independent reconstruction of
that attribution. This is the selected resolution of OD-1.
The [Path declarations](../../path.go), [metadata contract](../config.md#layered-metadata),
[resolution contract](../config.md#recorded-application-resolution), and
[strict-composition contract](../config.md#strict-document-composition) remain
authoritative. This work changes the implementation of those contracts without
selecting a new public API, path syntax, precedence rule, or configuration
restriction. The existing root-owned facade and engine responsibilities remain.
## Current Baseline And Targeted End State
An explicit mapping-key path and an explicit sequence-index path can both render
as `/shape/0`. `ParsePath` deliberately leaves numeric tokens contextual, whereas
`Keys` and `Index` preserve the caller's explicit choice. The engine already has
shape-aware effective/historical path indexes and a frozen resolution-entry view,
but some queries and metadata updates still use pointer spelling alone.
History currently reconstructs definition and previous-origin records, while
explanation replays source contributions, inheritance, decoding checkpoints, CLI
assignments, and grouped resolution snapshots. Neither public representation
contains everything needed by the other. The target is shared underlying evidence
with distinct projections for each interface, preserving their existing behavior.
| Finding | Current discrepancy | Target |
| --- | --- | --- |
| 1: resolution presence | `IsExplicit` and `Default` consult string-keyed presence after schema validation, which cannot determine actual shape beneath dynamic values. | Presence decisions use the matching semantic path in the fixed resolution-entry state. |
| 2: effective explicitness | Resolution retains old field information when pointer spelling survives a container-shape change. | Input explicitness carries forward for the same semantic path; a newly introduced key or index does not inherit another path's explicitness. |
| 3: history attribution | A historical-shape check admits the query, then `History` returns the entire textual-pointer bucket; explanation selects evidence separately. | Both interfaces derive attribution from one shared history model, selecting exact definitions, previous origins, and applicable ancestor operations by semantic path. |
| 4: claimant order | Composition constructs related locations in assembly order, then canonicalization sorts them by location. | Structured and rendered composition conflicts preserve the selected claimant order. |
For example, if input contains `shape: {'0': old}` and recorded application
resolution changes `shape` to a sequence, the old key remains an input path in
the callback's entry view. The new index is effective in final metadata but was
not explicitly supplied by that input. Its history must not present the old key's
definition as its own. This distinction concerns container shape, not identity
tracking for records that move within a sequence.
## Required Changes
### 1. Use Entry-State Shape For Resolution Presence
In [resolution.go](../../internal/engine/resolution.go), align `IsExplicit` and
the explicitness decision in `Default` with the frozen, shape-aware entry metadata
already used by `InputInfo` in
[load_metadata.go](../../internal/engine/load_metadata.go).
Reuse the existing semantic-path matching machinery. A shared private lookup can
avoid divergent presence decisions without requiring every public query to have
identical error behavior. In particular:
- Preserve `IsExplicit`'s schema-path validation and its existing diagnostics.
`InputInfo` remains a query that returns false for missing or mismatched paths
without those diagnostics.
- Preserve `Default`'s helper-target validation, assignment, recording, and final
helper-value checks. Correct its presence gate; do not bypass the other checks.
- Use entry-state shape even after application code changes the working value.
Explicit false, zero, empty, and null retain their existing presence meaning.
- Keep callback lifetime and detached metadata behavior intact.
A shape mismatch beneath a schema-valid dynamic path must not become explicit
merely because its pointer spelling matches an input path. Contextually parsed
numeric tokens continue to resolve against the entry state's actual containers.
### 2. Preserve Explicitness By Semantic Path During Resolution
Update resolution metadata application and field refresh in
[load_metadata.go](../../internal/engine/load_metadata.go) to compare the relevant
entry and final shapes before reusing presence information.
The comparison must account for container kinds along the full path. A mapping
key becoming a sequence index at an ancestor can change a descendant's semantic
path even when its last segment and full pointer spelling stay unchanged.
Keep explicitness for matching surviving paths, including descendants covered by
an ancestor normalization record. New semantic paths introduced by resolution
remain non-explicit, and retain the applicable recorded resolution origin.
Removed paths leave effective metadata while remaining available to historical
queries under the existing contract. Do not clear every descendant's presence
just because its parent was normalized.
Use the existing before/after projection and entry metadata where possible.
This work does not require tracking every transient mutation inside a callback;
the existing net-change and coalesced-record model continues to apply.
### 3. Build A Shared Internal History Model
#### Evidence And Ownership
Give historical evidence one private owner in the engine. Composition,
inheritance, decoding, CLI assignment, and resolution continue to perform their
existing work and supply evidence to that owner. The model does not drive a
second configuration load or repeat application callbacks.
Organize the evidence into ordered groups with the phase boundaries required by
the existing inspection contracts:
| Evidence group | Information to preserve |
| --- | --- |
| Default baseline | Canonical typed application defaults or Go-zero values and their default attribution. |
| Source contributions | Underlay, main-tier, and selected-overlay contributions in their existing order; exact definitions, composite origins, displaced definitions, raw policy-validated values, and sequence-position projections. |
| Inheritance | Parent transfers, child contributions including equal-valued replacements, controlling directives and their consumption, identity-field preservation, and contribution origins/dependencies. |
| Typed checkpoints | Pre-CLI and post-CLI typed projections and attribution needed to reconcile retained defaults and raw contributions with decoded values. |
| CLI contributions | Explicit assignments, including equal-valued assignments, raw supplied representation, source attribution, and affected paths. |
| Resolution | Original recorded operations, reasons, dependencies, and before/after evidence, with the grouping needed to explain overlapping records as net subtree changes. |
The logical order remains baseline, source contributions, inheritance, pre-CLI
decoding reconciliation, CLI contributions, post-CLI typed reconciliation, then
recorded resolution. The two typed checkpoints belong on their respective sides
of the CLI contribution phase.
The groups describe logical inspection chronology, not a transcript of every
physical mutation. Retain the existing net-change and coalescing rules for
resolution. Preserve superseded raw input that need not decode into the final
application type; typed checkpoints do not replace that evidence.
Use a small set of ordinary private structures and existing document/path
mechanisms. Exact types, indexes, and storage layout belong in the implementation
plan. Complete evidence means enough for the existing history, explanation, and
internal attribution consumers; it does not add persistence, a public event API,
or recording of transient application effects.
#### Shared Selection And Attribution
Retain concrete semantic-path evidence when it is available. A query over the
model must distinguish exact definitions from operations affecting an ancestor
or descendant, determine the relevant previous origins, and apply sequence
contribution coordinates at their assigned positions.
The affected semantic path and the operation's reported path are distinct facts.
For example, a replacement at `/shape` can remove a mapping key and introduce a
sequence index with the same pointer spelling. Preserve enough before/after shape
evidence to answer each explicit query correctly. Merely filtering `Change.Path`
after concrete segment kinds have been discarded is insufficient.
Make this selection and attribution common to both projections, including
inheritance integration and `Previous` reconstruction. A false definition must
not survive as a previous origin after it has been excluded from the query's
history. Preserve genuine ancestor replacement/removal effects and chronology.
A path that never existed does not acquire history solely because an ancestor
changed.
Keep the contextual meaning of parsed numeric tokens across historical states
and the one-entry-per-canonical-pointer enumeration of `HistoricalPaths`.
Indexes and compact derived records may remain, but their attribution must come
from the shared evidence. Metadata queries should not require full configuration
replay or YAML rendering to obtain definition and origin records.
#### Distinct Public Projections
`Metadata.History` produces detached definition, inheritance, and resolution
records from the model. `Explain` reconstructs and safely renders values from
the same evidence, retaining its existing decoding and grouping behavior. Neither
public API should be implemented by translating the other's public result.
Preserve these intentional differences:
| Case | Metadata history | Explanation |
| --- | --- | --- |
| Several strict contributions to a mapping | Composite definition/origin information. | Each contribution's effect on the mapping. |
| Raw input differs from canonical typed values | Definition and attribution records. | The applicable decoding reconciliation transition. |
| Overlapping resolution records | Specific recorded attribution under the existing coverage rules. | One net subtree event with combined reasons and dependencies. |
| Equal-valued input or inheritance operations | Explicit definition and operation attribution remains visible. | Relevant operations remain visible even when before and after values are equal. |
Agreement means consistent semantic identity and attribution, not identical
record lists. Retain original resolution records as evidence from which net
replay groups are derived; a grouped explanation alone loses information needed
by metadata.
#### Replace Existing Attribution Machinery
The common model must replace independent historical attribution, rather than
become another representation alongside it. The implementation plan should map
the following owners to their replacements:
| Current owner | Required change |
| --- | --- |
| History-building portions of `metadataFromDecoded`, `applyOverlayMetadata`, and `appendPostDecodeCLIHistory` in [load_metadata.go](../../internal/engine/load_metadata.go) and [override_context.go](../../internal/engine/override_context.go) | Produce or project shared evidence instead of independently reconstructing pointer-keyed histories. Retain effective-field construction where it is still needed. |
| Previous-origin reconstruction and inheritance insertion in [inheritance_history.go](../../internal/engine/inheritance_history.go) | Use common semantic selection and attribution while preserving the existing inheritance projection rules. |
| Relevance and chronology orchestration in [explain.go](../../internal/engine/explain.go) | Consume the shared model; retain useful value reconstruction and redaction helpers. |
| Separate published inheritance evidence for metadata and explanation in [load_result.go](../../internal/engine/load_result.go) | Consolidate under the frozen history owner where independent copies are unnecessary; keep required output detachment. |
| Original and coalesced resolution evidence in [history.go](../../internal/engine/history.go) | Own original records in the model and derive the replay grouping without losing specific attribution or maintaining a second independently interpreted history. |
Existing source, sequence-combination, and inheritance evidence producers may
remain useful. Their algorithms need not be rewritten merely to contribute to
the model. Likewise, final effective fields, the frozen resolution-entry view,
source records, and final configuration snapshots retain their responsibilities.
Findings 1 and 2 still require explicit presence corrections.
#### Other Consumers, Lifetime, And Resource Accounting
Move historical dependency reads in
[secret classification](../../internal/engine/secret.go) and historical location
fallbacks in [override_context.go](../../internal/engine/override_context.go) to
the shared evidence. Do not retain the old history machinery solely for those
readers. Preserve existing sensitivity propagation and historical classification;
correcting attribution does not authorize narrowing redaction for paths that
share a pointer spelling.
Evidence must be available at the lifecycle stages where these internal readers
need it, including diagnostic attribution before publication. Keep the callback's
entry metadata fixed while later resolution evidence is added. Finalize the
model for published queries without introducing lazy mutation of shared state.
Private frozen evidence may be shared by the two views while their returned
records remain detached under the existing ownership contract.
Reuse retained trees and existing lookup machinery where practical. Review
retained-history accounting against the resulting ownership graph, including
typed checkpoints and inheritance/resolution evidence. The model does not require
a full document copy per event, a new quota, or a new concurrency guarantee.
Reduced duplication is a design benefit to evaluate, not a promised memory or
speed improvement.
### 4. Preserve Composition Claimant Order Through Diagnostics
Keep the order produced by `compositionConflictDiagnostic` in
[compose.go](../../internal/engine/compose.go) through accumulation, `LoadError`,
facade conversion, and `WriteDiagnostics` in
[diagnostic.go](../../internal/engine/diagnostic.go).
Prefer a narrow internal canonicalization rule based on the diagnostic semantics
already carried across these boundaries. Identify the composition diagnostic
producers governed by the claimant-order contract rather than changing all
related-location lists. Preserve canonical ordering of diagnostic records and
the existing sorting of related locations for other diagnostics.
The root renderer converts public diagnostic records back to engine records and
canonicalizes them again. An engine-only marker discarded by facade conversion
would therefore be insufficient. No public ordering option is needed for this
correction. Keep primary selection, conflict detection, acquisition order, and
dependency-ready scheduling unchanged.
## General Implementation Order
1. Prepare the concrete shared-evidence design and focused regression cases.
Map existing producers and consumers to the new owner, distinguish common
attribution from presentation rules, and identify the old paths to remove.
Select representations and indexes that preserve existing query and lifetime
needs without adding public contracts.
2. Repair entry-state presence queries and `Default` (finding 1). This is a local
correction that can proceed independently of the larger history work.
3. Repair final explicitness preservation (finding 2), retaining the shape evidence
needed by metadata updates. Coordinate this work with history construction so
the two fixes do not introduce competing definitions of path identity.
4. Build the shared model and migrate metadata history, explanation, sensitivity
dependencies, and diagnostic-location readers. Preserve phase evidence and
derive both public projections through common semantic attribution. Remove
the superseded reconstruction paths and reconcile retained-history accounting.
Verify finding 3 and its interaction with final presence metadata; repairing
pointer buckets alone does not complete the selected architecture.
5. Complete composition diagnostic ordering (finding 4) as an independent change;
it may proceed alongside the path work. Check rendering as well as the
structured error before considering it complete.
6. Review the integrated result, run applicable development checks, and update
affected implementation documentation and finding status.
These are high-level dependencies, not a requirement to combine unrelated fixes
in one commit. The implementation plan should select concrete edits and test
commands while keeping the four findings individually traceable.
## Validation And Completion
Follow the [testing policy](../policy/testing.md) and use the
[development guide](../development.md#baseline-validation) for checks. Extend
existing test owners rather than recreating full internal matrices at the facade.
The focused cases should cover:
- Both mapping-key/index mismatch directions beneath dynamic values, including
explicit paths and contextual parsed pointers. Check callback presence and
`Default` behavior, including the fixed entry view after working-value changes.
- Both container-shape transitions during recorded resolution, a descendant
affected by an ancestor shape change, and a same-shape normalization control.
Check explicitness and recorded origins through final metadata and explanation.
- Shape-changing CLI and resolution history, checking exact definitions,
`Previous`, and genuine ancestor replacement/removal. Include contextual
enumeration/query coverage and preserve existing inheritance and overlay cases;
add another case only where those integrations expose an unprotected risk.
- Shared-model projections across composite mapping origins, typed decoding and
default reconciliation, equal-valued writes, inheritance controls/transfers,
mapped sequence replacements and removed descendants, and overlapping
resolution records. Reuse existing cases where they protect those semantics;
check the intentional differences between the public projections as well as
agreement on attribution.
- Historical sensitivity/dependency propagation, diagnostic source-location
fallback, detached returned records, and complete retained-history accounting
under the new ownership graph. Use small controlled budget cases and existing
disclosure fixtures rather than memory or timing thresholds.
- A composition conflict whose acquisition and assembly orders differ, using the
audit fixture. Check both published diagnostics and rendered related-location
order, while retaining coverage for ordinary diagnostic canonicalization.
Retain existing checks for invalid schema paths, callback lifetime, detached
records, safe historical rendering, and default/null presence semantics. Test
observable behavior rather than a particular private index or number of copies.
No benchmark campaign, broader audit, or new release gate is part of this roadmap.
Completion requires all four findings to have implementation and regression
evidence, appropriate development checks, and both history interfaces deriving
semantic attribution from the shared model. Review the final producer/consumer
map to confirm superseded independent reconstruction has been removed and
internal attribution readers have migrated. Passing the four defect regressions
while retaining the old parallel attribution machinery is not completion.
The audit's previous passing suite is not validation of these future changes.
Scope here does not promise identical output from different inspection interfaces
or a universal guarantee about unrelated path-processing code.
## Decision Status
OD-1 is settled: build one shared internal history model with distinct metadata
and explanation projections. The design investigation found that both views
require evidence the other's public representation does not preserve. Shared
ownership can replace independent attribution and redundant retained evidence,
while keeping the current lifecycle and public contracts.
The narrower repair of stored history events would leave the separate replay
attribution owner in place. Promoting current explanation output to authoritative
history would lose composite-definition and specific resolution information.
The selected common underlying model addresses both concerns. This accepts a
larger internal refactor than a local shape-filtering fix.
No significant open decision remains at roadmap level. Private representation,
indexing, and helper choices belong in the implementation plan. This roadmap
records future implementation ownership; it does not revise accepted ADRs or
declare the model implemented.

202
docs/roadmap/interfaces.md Normal file
View File

@@ -0,0 +1,202 @@
# API and Interface Contract Audit
Reviewed baseline: `01b08e76a3fecc22c4414437bfeaa07b4b7a85aa`.
Status: four open findings. This is an audit record, not an implementation plan.
No production code or repository tests were changed.
The [interface remediation roadmap](interface-remediation.md) describes the
settled target, required changes, and dependencies, including a shared internal
history model for metadata history and explanation. Implementation remains pending.
This audit retains the findings and reproduction evidence.
## Scope and evidence
Reviewed the root-owned public facade and its conversions, engine loading and
callback boundaries, source/discovery and filesystem ownership, schema and typed
value handling, metadata/history, safe rendering and comparison, secrets, and
the document/YAML boundary against declaration comments, the
[configuration contract](../config.md), and applicable architectural decisions.
The root delegates to the synchronous engine lifecycle; parser-independent
documents support source assembly and inheritance, and completed results retain
detached configuration and frozen inspection state.
The findings below were reproduced through the public API with a disposable Go
program outside the repository, using labeled byte sources and `fstest.MapFS`.
The full existing suite passed with Go 1.26.10 on Linux:
```sh
GOTOOLCHAIN=local GOTELEMETRY=off GOPROXY=off go test ./... -count=1
```
The existing tests cover many ownership, lifetime, error, and zero-value cases,
but do not detect these cross-interface discrepancies. No benchmarks, external
service calls, or new test files were needed. The recommendations restore
existing contracts rather than introduce new API restrictions or guarantees.
## 1. Resolution presence queries confuse explicit keys with sequence indexes
Priority: P2. Status: open.
Relevant code:
- [`Resolution.IsExplicit`](../../internal/engine/resolution.go), lines 75–82.
- `Resolution.InputInfo` and `resolutionInput.lookup` in
[resolution.go](../../internal/engine/resolution.go) and
[load_metadata.go](../../internal/engine/load_metadata.go).
- `resolveSchemaPath` in [schema.go](../../internal/engine/schema.go).
The public [path contract](../../path.go) distinguishes explicit mapping keys
from sequence indexes. The [resolution contract](../config.md#recorded-application-resolution)
defines surviving-input presence and a fixed entry-state metadata view;
[metadata queries](../config.md#layered-metadata) preserve container shapes.
`IsExplicit` validates against the schema but then looks up presence using only
`path.String()`. In dynamic `any` state, schema validation cannot establish the
actual container shape. Consequently these distinct queries collide:
```go
key := configkit.Keys("shape", "0")
index := configkit.Keys("shape").Index(0)
```
Load `map[string]any` from `shape: [input]` with a resolver that queries both
paths, without changing the configuration. Observed results:
| Query | `Resolution.IsExplicit` | `Resolution.InputInfo` found | Final `Metadata.IsExplicit` |
| --- | --- | --- | --- |
| Explicit mapping key | true | false | false |
| Explicit sequence index | true | true | true |
The mapping-key path never existed and should not be reported as explicit.
Applications branching on presence receive inconsistent answers depending on
which public query they use, even during an otherwise unchanged load.
Potential remediation: use the retained entry-state semantic shape when checking
resolution presence, consistently with `InputInfo` and `Metadata.IsExplicit`.
Review the same string-only presence lookup in `Default` while preserving its
existing assignment and callback-lifetime rules. Add focused public coverage for
both mapping-to-index and index-to-mapping mismatches under dynamic values,
including contextually parsed pointers. This does not require changing the
documented treatment of invalid schema paths.
## 2. Resolution can transfer explicitness to a different semantic path
Priority: P2. Status: open.
Relevant code: `applyResolutionMetadata`, `applyResolutionFieldOrigins`, and
`refreshResolutionFields` in
[load_metadata.go](../../internal/engine/load_metadata.go), especially lines
371–387; final shape checking in
[`Metadata.effectiveField`](../../internal/engine/metadata.go).
The [metadata contract](../config.md#layered-metadata) distinguishes key/index
shapes and keeps derived values for absent input non-explicit. Recorded
resolution changes origins without creating input explicitness.
Reproduction:
1. Load `map[string]any` from `shape: {'0': old}`.
2. In resolution, replace `configuration["shape"]` with `[]any{"new"}` and
record normalization at `Keys("shape")`.
3. Query final metadata using the two paths from finding 1.
The old explicit mapping-key path correctly stops being effective, but the newly
created sequence index reports `Explicit: true`. That sequence index was never
supplied by input. `refreshResolutionFields` reuses the old `FieldInfo` whenever
its textual pointer survives; it updates null state without checking whether
the old and new containers give that pointer different semantic shapes. Final
lookup validates only the new shape, so it cannot recover the lost distinction.
This can mislead consumers inspecting whether a final value was explicitly
configured rather than introduced by application resolution. The converse
sequence-to-numeric-key transition has the same underlying collision.
Potential remediation: retain entry/effective shape evidence across resolution
and carry input explicitness forward only for the matching semantic path.
Preserve existing explicitness for same-shape paths and the recorded resolution
origin; do not clear every descendant's presence merely because an ancestor was
normalized. Cover both shape transitions and agreement among `Lookup`,
`IsExplicit`, and explanation. Fixing finding 1's callback query alone does not
repair this publication path.
## 3. Metadata history mixes numeric-key and sequence-index definitions
Priority: P2. Status: open.
Relevant code:
- [`Metadata.History`](../../internal/engine/metadata.go), lines 56–65.
- `metadataFromDecoded`, `applyOverlayMetadata`, and history event construction
in [load_metadata.go](../../internal/engine/load_metadata.go).
- Shape-aware replay in [`Inspection.Explain`](../../internal/engine/explain.go).
The [metadata contract](../config.md#layered-metadata) promises semantic
key/index distinctions for historical as well as effective queries. The
historical-path index retains both shapes, but `History` uses that index only to
check that the query was ever known. It then retrieves the entire history bucket
by textual pointer, without selecting the query's shape.
Reproduction: load `shape: {'0': old}` into `map[string]any`, then apply a CLI
override at `Keys("shape")` with `[]any{"new"}`. Both explicit shapes were
observed and share `/shape/0`.
`History(Keys("shape").Index(0))` incorrectly includes the original base
definition of the mapping key, and identifies that base origin as the previous
origin of the CLI operation. The old input defined a key, not an index. In the
same successful result, `Explain` for the explicit index correctly shows only
its CLI introduction. `History` for the key and index returns identical records
despite their different lifetimes.
Consequences include false source attribution and disagreement between the two
public history interfaces when consumers inspect removed or introduced paths.
Potential remediation: retain enough container-shape evidence with stored
history events, or select relevant events from existing shape-aware replay
evidence. Filter exact definitions and previous origins by the queried semantic
shape while retaining genuine ancestor replacement/removal events. Filtering
only on the current `Change.Path` is insufficient where construction already
replaced concrete segment kinds with parsed tokens. Preserve the intentional
one-pointer-per-entry `HistoricalPaths` enumeration and contextual behavior of
`ParsePath`; neither public contract needs redesign. Cover both direction changes
and compare metadata history with explanation.
## 4. Diagnostic canonicalization breaks composition claimant ordering
Priority: P3. Status: open.
Relevant code: `compositionConflictDiagnostic` in
[compose.go](../../internal/engine/compose.go) and `canonicalDiagnostics` in
[diagnostic.go](../../internal/engine/diagnostic.go), particularly lines 88–92.
The latter runs both while constructing `LoadError` and in `WriteDiagnostics`.
The [strict-composition contract](../config.md#strict-document-composition)
specifies the first claim in scoped assembly order as primary and every remaining
claim in the same order as related locations.
A native-filesystem fixture demonstrates the discrepancy:
```text
main.yml imports a.yml, b.yml, c.yml
a.yml imports z.yml
b.yml, c.yml, and z.yml each define x
```
Acquisition assigns main=source-1, a=source-2, z=source-3, b=source-4,
c=source-5. Dependency-ready assembly considers the three conflicting claims
in b, c, z order. The composer correctly chooses b as primary and constructs
related locations c, z. Generic canonicalization sorts those related locations
by acquisition ID, publishing z, c instead. The reproduced public diagnostic
has primary source-4 and related source-3, source-5.
All claimants remain present and the conflict still fails loading, so this is
lower impact than the presence/history defects. It nevertheless violates the
documented reporting order and gives consumers two different ordering rules
within one conflict report.
Potential remediation: preserve the selected semantic ordering for composition
claimant lists through accumulation and rendering, while retaining canonical
ordering of diagnostic records and the established sorting of related locations
for diagnostics without a semantic-order contract. Add one public failure-path
case whose acquisition and claimant orders differ and verify both structured
diagnostics and rendered output. No scheduler or precedence change is needed.

View File

@@ -0,0 +1,186 @@
# 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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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.

276
docs/roadmap/roadmap.md Normal file
View File

@@ -0,0 +1,276 @@
# 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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#settled-scope-decisions).
It is intended for maintainers reviewing the remediation's scope and architecture.
## Document Ownership
- [Archived audit](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md) records 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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#b-callback-and-filesystem-lifetime)
and **C01, C02** in [filesystem traversal](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#e-collection-processing-and-efficiency)
and **F08** in [secret-failure lookup](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/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

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect"
"testing" "testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "gitea.maximumdirect.net/eric/ConfigKit"
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
func TestFacadeCompatibility(t *testing.T) { func TestFacadeCompatibility(t *testing.T) {
t.Parallel() t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []struct {
name string
type_ reflect.Type
}{
{"Path", reflect.TypeFor[configkit.Path]()},
{"Source", reflect.TypeFor[configkit.Source]()},
{"Secret", reflect.TypeFor[configkit.Secret]()},
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
} {
if candidate.type_.PkgPath() != packagePath {
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
}
}
constructed := configkit.Options[facadeCompatibilityConfig]{ constructed := configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{}, Definition: configkit.Definition[facadeCompatibilityConfig]{},
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"}, Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
@@ -56,11 +38,6 @@ func TestFacadeCompatibility(t *testing.T) {
if got := constructed.Overrides[0].Path.String(); got != "/value" { if got := constructed.Overrides[0].Path.String(); got != "/value" {
t.Fatalf("constructed override path = %q, want /value", got) t.Fatalf("constructed override path = %q, want /value", got)
} }
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
if publicResult.Config.Value != "constructed" {
t.Fatal("constructed result lost its public configuration")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{ result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{ Definition: configkit.Definition[facadeCompatibilityConfig]{
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error { Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {

View File

@@ -23,6 +23,9 @@ type equivalentConfig struct {
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) { func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n" const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
bytesData := []byte(yaml)
bytesSource := configkit.Bytes("memory", bytesData)
bytesData[0] = 'X'
hostPath := filepath.Join(t.TempDir(), "main.yml") hostPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil { if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
}{ }{
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile}, {"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem}, {"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes}, {"bytes", bytesSource, context, configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader}, {"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
{"mapping", configkit.Mapping("programmatic", map[string]any{ {"mapping", configkit.Mapping("programmatic", map[string]any{
"version": uint64(1), "name": "retained", "enabled": false, "count": 0, "version": uint64(1), "name": "retained", "enabled": false, "count": 0,
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed { if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("reader re-consumption = %s", got) t.Fatalf("reader re-consumption = %s", got)
} }
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
readerCopy := copiedReader
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
t.Fatal(err)
}
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("copied reader re-consumption = %s", got)
}
} }
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) { func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {

View File

@@ -0,0 +1,62 @@
package configkit_test
import (
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte(
"retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n",
)), configkit.Options[map[string]any]{Overrides: []configkit.Override{
{Path: configkit.Keys("retired"), Value: nil, Flag: "--retired"},
{Path: configkit.Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
}})
if err != nil {
t.Fatalf("Load: %v", err)
}
metadata := loaded.Metadata()
paths := metadata.HistoricalPaths()
got := make([]string, len(paths))
for index, path := range paths {
got[index] = path.String()
if len(metadata.History(path)) == 0 {
t.Errorf("enumerated path %s has no history", path)
}
}
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("historical paths = %v, want sorted unique pointers %v", got, want)
}
if _, ok := metadata.Lookup(paths[4]); ok {
t.Fatal("removed descendant remains effective")
}
if _, ok := metadata.Lookup(paths[6]); !ok {
t.Fatal("enumerated numeric path did not follow the effective sequence shape")
}
if len(metadata.History(paths[6])) == 0 {
t.Fatal("enumerated numeric path lost history")
}
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
t.Fatal("mapping-key path resolved against the effective sequence")
}
if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
t.Fatal("zero metadata contains historical paths")
}
paths[1] = configkit.Keys("mutated")
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
t.Fatalf("returned path slice mutated retained metadata: %v", pathStrings(again))
}
}
func pathStrings(paths []configkit.Path) []string {
result := make([]string, len(paths))
for index, path := range paths {
result[index] = path.String()
}
return result
}

View File

@@ -6,8 +6,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
) )
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) { func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
@@ -125,20 +123,61 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 { if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
t.Fatalf("required empty selection was recorded as optional: %#v", sources) t.Fatalf("required empty selection was recorded as optional: %#v", sources)
} }
}
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit: func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
type config struct {
Version string `yaml:"version"`
Imported string `yaml:"imported"`
}
for _, layered := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
configkit:
imports: imports:
- dir: recursive-files - dir: parts
`) `)
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n") writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
recursive := true
patterns := []string{"*.cfg"} recursive := true
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}}) patterns := []string{"*.cfg"}
recursive = false definition := Definition[config]{Version: &VersionRule{
patterns[0] = "[" Path: Keys("version"),
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions) Check: func(version Version, _ *Validation) error {
if err != nil || loadedEngine.Config.Value != "selected" { if version != (Version{Present: true, Text: "v1"}) {
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err) t.Errorf("version callback received %#v", version)
}
recursive = false
patterns[0] = "*.skip"
return nil
},
}}
load := func() (*Result[config], error) {
defaults := ImportDefaults{Recursive: &recursive, Pattern: patterns}
if layered {
return LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, ImportDefaults: defaults,
})
}
return Load(File(mainPath), Options[config]{
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, Imports: true, ImportDefaults: defaults,
})
}
first, err := load()
if err != nil || first.Config.Imported != "selected" {
t.Fatalf("load with pre-callback defaults = %#v, %v", first, err)
}
if recursive || patterns[0] != "*.skip" {
t.Fatalf("version callback did not mutate caller defaults: recursive=%v pattern=%q", recursive, patterns[0])
}
second, err := load()
if err != nil || second.Config.Imported != "" {
t.Fatalf("subsequent load reused earlier defaults: %#v, %v", second, err)
}
})
} }
} }

View File

@@ -1,52 +0,0 @@
package configkit
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
data := []byte("value: retained\n")
source := Bytes("snapshot.yml", data)
data[7] = 'X'
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
if err != nil || result.Config.Value != "retained" {
t.Fatalf("snapshotted source = %#v, %v", result, err)
}
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
first := readerSource
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
t.Fatal(err)
}
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
var loadError *engine.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
t.Fatalf("copied reader source error = %#v", err)
}
}
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
t.Parallel()
parsed, err := ParsePath("/0")
if err != nil {
t.Fatal(err)
}
keyed, indexed := Keys("0"), Path{}.Index(0)
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
}
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
}
}

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.26.0
require go.yaml.in/yaml/v4 v4.0.0-rc.6 require go.yaml.in/yaml/v4 v4.0.0-rc.6
require ( require (
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
github.com/yuin/goldmark/v2 v2.1.6 // indirect github.com/yuin/goldmark/v2 v2.1.6 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/mod v0.40.0 // indirect
) )

4
go.sum
View File

@@ -1,5 +1,5 @@
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI= gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI= gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE= github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ= github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=

View File

@@ -3,7 +3,7 @@ package engine
import "strconv" import "strconv"
type acquisitionPolicy struct { type acquisitionPolicy struct {
importDefaults importDefaults importDefaults ImportDefaults
sequencePolicy sequencePolicy sequencePolicy sequencePolicy
importLimits importLimits importLimits importLimits
} }
@@ -23,7 +23,6 @@ type inputAcquisitionRequest struct {
role sourceVersionRole role sourceVersionRole
directiveContext rootDirectiveContext directiveContext rootDirectiveContext
protectionRule *VersionRule protectionRule *VersionRule
deferAssembly bool
ordinaryOverlay string ordinaryOverlay string
} }
@@ -38,9 +37,6 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
destination := request.input.destination destination := request.input.destination
accounting := state.accounting accounting := state.accounting
versionState := state.versionState versionState := state.versionState
visited := tier.visited
hostToken := tier.hostToken
role := request.role
sourceID := "source-" + strconv.Itoa(accounting.sources+1) sourceID := "source-" + strconv.Itoa(accounting.sources+1)
selectedSource := source selectedSource := source
if source.kind == sourceSearch { if source.kind == sourceSearch {
@@ -99,67 +95,10 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
return input, directiveDiagnostics return input, directiveDiagnostics
} }
} }
var versionProcessing *sourceVersionProcessingResult request.directiveContext = directiveContext
if directiveContext == rootDirectiveContextSource { versionProcessing, diagnostics := processAcquiredInputVersion(parsed, request, versionState)
var sourceID string if len(diagnostics) != 0 {
if parsed.source != nil { return input, diagnostics
sourceID = parsed.source.ID
}
var processed sourceVersionProcessingResult
var failures []Diagnostic
if versionState != nil {
processed, failures = versionState.processWithDirectiveContext(parsed.document, role, destination, sourceID, request.input.placementMode, directiveContext)
} else {
processed = processSourceVersionWithDirectiveContext(parsed.document, request.protectionRule, role, destination, sourceVersionModeDefault, directiveContext)
failures = processed.diagnostics
}
if len(failures) != 0 {
return input, failures
}
versionProcessing = &processed
if role == sourceVersionMain && versionState != nil {
versionState.mainProcessing = &processed
}
} else if directiveContext == rootDirectiveContextLayered {
extracted, failures := extractRootDirectives(parsed.document, rootDirectiveContextLayered)
if len(failures) != 0 {
return input, failures
}
processed := sourceVersionProcessingResult{
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
directiveContext: rootDirectiveContextLayered,
}
if versionState != nil && versionState.mode != sourceVersionModeDefault {
var sourceID string
if parsed.source != nil {
sourceID = parsed.source.ID
}
versionProcessed, versionFailures := versionState.process(extracted.body, role, destination, sourceID, request.input.placementMode)
if len(versionFailures) != 0 {
return input, versionFailures
}
versionProcessed.imports = extracted.imports
versionProcessed.sequences = append(cloneSequenceDeclarations(extracted.sequences), versionProcessed.sequences...)
versionProcessed.directiveContext = rootDirectiveContextLayered
processed = versionProcessed
if role == sourceVersionMain {
versionState.mainProcessing = &processed
}
}
versionProcessing = &processed
} else if versionState != nil && versionState.mode != sourceVersionModeDefault {
var sourceID string
if parsed.source != nil {
sourceID = parsed.source.ID
}
processed, failures := versionState.process(parsed.document, role, destination, sourceID, request.input.placementMode)
if len(failures) != 0 {
return input, failures
}
versionProcessing = &processed
if role == sourceVersionMain {
versionState.mainProcessing = &processed
}
} }
request.input.source = selectedSource request.input.source = selectedSource
if request.input.imports && selectedSource.kind != sourceNone { if request.input.imports && selectedSource.kind != sourceNone {
@@ -173,69 +112,168 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
input.document = input.composition.root input.document = input.composition.root
} }
} else { } else {
input.sources = sourceInfos(parsed.source) input, diagnostics = acquireStandaloneInput(input, request, versionProcessing, policy, tier)
if versionProcessing != nil { if len(diagnostics) != 0 {
input.document = versionProcessing.payload return input, diagnostics
}
if request.input.filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
resolved, diagnostic := validateFilesystemContext(selectedSource, request.input.filesystem, parsed, tier.hostToken)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
} }
} }
if !request.input.imports && input.document != nil { return completeAcquisitionContexts(input, request, tier)
_, placed, err := placeDocumentWithMode(input.document, destination, request.input.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes) }
if err != nil {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")} func acquireStandaloneInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
} parsed := input.parsed
localPayload := input.document input.sources = sourceInfos(parsed.source)
input.document = placed if processing != nil {
identity := importKeyForMainSource(parsed.source) input.document = processing.payload
var sequenceDeclarations []sequenceDeclarationSpec
if versionProcessing != nil {
sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences)
}
input.acquisition = []acquisitionScope{{
owner: identity,
destination: destination.clone(),
contributions: []acquiredContribution{{
source: cloneSourceInfo(*parsed.source), payload: localPayload,
identity: identity, role: importRoleMain, scopeOwner: identity,
document: placed, destination: destination.clone(), processing: versionProcessing,
sequenceDeclarations: sequenceDeclarations,
}},
}}
rawNodes := countInputNodes(parsed.document)
placementNodes := destinationWrapperNodeCountForMode(destination, request.input.placementMode)
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
}
if diagnostic := registerAcquiredSource(selectedSource, request.input.filesystem, parsed.source, visited, hostToken); diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
accounting.sources++
accounting.bytes += parsed.bytes
accounting.nodes += rawNodes + placementNodes
} }
if !activeOverlay(request.ordinaryOverlay) || request.input.imports { if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (selectedSource.kind == sourceFile || request.input.filesystem != nil || nativeContext(selectedSource, request.input.filesystem)) { resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
resolved, diagnostic := resolveFilesystemContext(selectedSource, request.input.filesystem, parsed, tier.hostToken)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
}
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
if diagnostic != nil { if diagnostic != nil {
return input, []Diagnostic{*diagnostic} return input, []Diagnostic{*diagnostic}
} }
input.contexts = contexts input.resolvedFilesystem = &resolved
} }
if request.input.imports || input.document == nil {
return input, nil
}
limits := policy.importLimits
placement := request.input.destination
documentLimits := defaultParseLimits().document
_, placed, err := placeDocumentWithMode(input.document, placement, request.input.placementMode, input.document.Location, documentLimits.Depth, documentLimits.Nodes)
if err != nil {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
}
localPayload := input.document
input.document = placed
identity := importKeyForMainSource(parsed.source)
var sequenceDeclarations []sequenceDeclarationSpec
if processing != nil {
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
}
input.acquisition = []acquisitionScope{{
owner: identity,
destination: placement.clone(),
contributions: []acquiredContribution{{
source: cloneSourceInfo(*parsed.source), payload: localPayload,
identity: identity, role: importRoleMain, scopeOwner: identity,
document: placed, destination: placement.clone(), processing: processing,
sequenceDeclarations: sequenceDeclarations,
}},
}}
rawNodes := countInputNodes(parsed.document)
placementNodes := destinationWrapperNodeCountForMode(placement, request.input.placementMode)
accounting := input.accounting
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
}
if diagnostic := registerAcquiredSource(input.source, request.input.filesystem, parsed.source, tier.visited, tier.hostToken); diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
accounting.sources++
accounting.bytes += parsed.bytes
accounting.nodes += rawNodes + placementNodes
return input, nil return input, nil
} }
func completeAcquisitionContexts(input assembledInput, request inputAcquisitionRequest, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
if activeOverlay(request.ordinaryOverlay) && !request.input.imports {
return input, nil
}
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (input.source.kind == sourceFile || request.input.filesystem != nil || nativeContext(input.source, request.input.filesystem)) {
resolved, diagnostic := resolveFilesystemContext(input.source, request.input.filesystem, input.parsed, tier.hostToken)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
}
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.contexts = contexts
return input, nil
}
func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRequest, state *sourceVersionState) (*sourceVersionProcessingResult, []Diagnostic) {
sourceID := ""
if parsed.source != nil {
sourceID = parsed.source.ID
}
var result sourceVersionProcessingResult
hasResult := false
updateMainProcessing := false
switch request.directiveContext {
case rootDirectiveContextSource:
if state != nil {
processed, diagnostics := state.processWithDirectiveContext(
parsed.document, request.role, request.input.destination, sourceID,
request.input.placementMode, request.directiveContext,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
result = processed
updateMainProcessing = request.role == sourceVersionMain
} else {
result = processSourceVersionWithDirectiveContext(
parsed.document, request.protectionRule, request.role,
request.input.destination, sourceVersionModeDefault, request.directiveContext,
)
if len(result.diagnostics) != 0 {
return nil, result.diagnostics
}
}
hasResult = true
case rootDirectiveContextLayered:
extracted, diagnostics := extractRootDirectives(parsed.document, request.directiveContext)
if len(diagnostics) != 0 {
return nil, diagnostics
}
result = sourceVersionProcessingResult{
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
directiveContext: request.directiveContext,
}
hasResult = true
if state != nil && state.mode != sourceVersionModeDefault {
processed, diagnostics := state.process(
extracted.body, request.role, request.input.destination,
sourceID, request.input.placementMode,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
processed.imports = extracted.imports
processed.sequences = append(cloneSequenceDeclarations(extracted.sequences), processed.sequences...)
processed.directiveContext = request.directiveContext
result = processed
updateMainProcessing = request.role == sourceVersionMain
}
default:
if state != nil && state.mode != sourceVersionModeDefault {
processed, diagnostics := state.process(
parsed.document, request.role, request.input.destination,
sourceID, request.input.placementMode,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
result = processed
hasResult = true
updateMainProcessing = request.role == sourceVersionMain
}
}
if !hasResult {
return nil, nil
}
if updateMainProcessing {
completed := result
state.mainProcessing = &completed
}
return &result, nil
}
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic { func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
if visited == nil || info == nil || info.Path == "" { if visited == nil || info == nil || info.Path == "" {
return nil return nil

View File

@@ -0,0 +1,73 @@
package engine
import (
"errors"
"testing"
)
func capturePanic(call func()) (recovered any) {
defer func() { recovered = recover() }()
call()
return nil
}
func requirePanicValue(t *testing.T, want any, call func()) {
t.Helper()
if got := capturePanic(call); got != want {
t.Fatalf("panic = %#v, want %#v", got, want)
}
}
func TestCallbackContextsCloseWhenCallbacksPanic(t *testing.T) {
type config struct {
Value string `yaml:"value"`
Version string `yaml:"version"`
}
t.Run("resolution", func(t *testing.T) {
sentinel := errors.New("resolution panic sentinel")
var retained *Resolution
got := capturePanic(func() {
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
panic(sentinel)
}}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.IsExplicit(Keys("value")) })
})
t.Run("validation", func(t *testing.T) {
sentinel := errors.New("validation panic sentinel")
var retained *Validation
got := capturePanic(func() {
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) {
retained = validation
panic(sentinel)
}}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Error(Keys("value"), "too late") })
})
t.Run("version", func(t *testing.T) {
sentinel := errors.New("version panic sentinel")
var retained *Validation
got := capturePanic(func() {
_, _ = testLoad[config](Bytes("version.yml", []byte("version: v1\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
panic(sentinel)
}},
}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Warning(Keys("version"), "too late") })
})
}

View File

@@ -14,11 +14,7 @@ type collectionPolicy struct {
type collectionPolicyResolution struct { type collectionPolicyResolution struct {
policy collectionPolicy policy collectionPolicy
operation sequenceMode operation sequenceMode
declarations []sequenceDeclarationSpec
declarationsPresent bool
identityDeclarationPresent bool
modeDeclarationPresent bool modeDeclarationPresent bool
applicationIdentityFields []string
applicationIdentityPresent bool applicationIdentityPresent bool
effectiveIdentityFields []string effectiveIdentityFields []string
} }
@@ -60,9 +56,6 @@ func resolveCollectionPolicy(
} }
} }
result := collectionPolicyResolution{ result := collectionPolicyResolution{
declarations: normalizedDeclarations,
declarationsPresent: len(normalizedDeclarations) != 0,
applicationIdentityFields: normalizedApplicationIdentityFields,
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0, applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
} }
@@ -73,7 +66,6 @@ func resolveCollectionPolicy(
var declaredModeLocation *Location var declaredModeLocation *Location
for _, declaration := range normalizedDeclarations { for _, declaration := range normalizedDeclarations {
if declaration.identityPresent { if declaration.identityPresent {
result.identityDeclarationPresent = true
if declaredIdentityFields == nil { if declaredIdentityFields == nil {
declaredIdentityFields = cloneStrings(declaration.identityFields) declaredIdentityFields = cloneStrings(declaration.identityFields)
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location) declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)

View File

@@ -0,0 +1,137 @@
package engine
// collectionPolicyRetirementIndex locates only assembly policies whose YAML
// identity or record-owned state can be cleared by an ancestor replacement.
// Mapping keys and sequence indexes remain distinct through pathSegment keys.
type collectionPolicyRetirementIndex struct {
children map[pathSegment]*collectionPolicyRetirementIndex
entryKey string
hasEntry bool
}
func newCollectionPolicyRetirementIndex(scope collectionPolicyScope) *collectionPolicyRetirementIndex {
index := &collectionPolicyRetirementIndex{}
for _, entry := range scope.collections {
if collectionPolicyHasRetirementState(entry.policy) {
index.set(entry)
}
}
return index
}
func (index *collectionPolicyRetirementIndex) set(entry collectionPolicyEntry) {
node := index
for _, segment := range entry.destination.segments {
if node.children == nil {
node.children = make(map[pathSegment]*collectionPolicyRetirementIndex)
}
if node.children[segment] == nil {
node.children[segment] = &collectionPolicyRetirementIndex{}
}
node = node.children[segment]
}
node.entryKey = entry.destination.String()
node.hasEntry = true
}
func (index *collectionPolicyRetirementIndex) remove(destination Path) {
if index == nil {
return
}
nodes := make([]*collectionPolicyRetirementIndex, 1, len(destination.segments)+1)
nodes[0] = index
node := index
for _, segment := range destination.segments {
node = node.children[segment]
if node == nil {
return
}
nodes = append(nodes, node)
}
node.entryKey = ""
node.hasEntry = false
pruneCollectionPolicyIndex(nodes, destination.segments)
}
func (index *collectionPolicyRetirementIndex) retire(scope *collectionPolicyScope, ancestor Path) {
if index == nil || scope == nil {
return
}
nodes := make([]*collectionPolicyRetirementIndex, 1, len(ancestor.segments)+1)
nodes[0] = index
node := index
for _, segment := range ancestor.segments {
node = node.children[segment]
if node == nil {
return
}
nodes = append(nodes, node)
}
index.clearSubtree(scope, node)
pruneCollectionPolicyIndex(nodes, ancestor.segments)
}
func (index *collectionPolicyRetirementIndex) clearSubtree(scope *collectionPolicyScope, node *collectionPolicyRetirementIndex) {
if node.hasEntry {
if entry, found := scope.collections[node.entryKey]; found {
clearCollectionIdentity(&entry.policy)
scope.collections[node.entryKey] = entry
}
node.entryKey = ""
node.hasEntry = false
}
for segment, child := range node.children {
index.clearSubtree(scope, child)
if child.empty() {
delete(node.children, segment)
}
}
if len(node.children) == 0 {
node.children = nil
}
}
func (index *collectionPolicyRetirementIndex) empty() bool {
return index == nil || !index.hasEntry && len(index.children) == 0
}
func pruneCollectionPolicyIndex(nodes []*collectionPolicyRetirementIndex, segments []pathSegment) {
for position := len(segments) - 1; position >= 0; position-- {
child := nodes[position+1]
if !child.empty() {
return
}
delete(nodes[position].children, segments[position])
}
}
func collectionPolicyHasRetirementState(policy collectionPolicy) bool {
return policy.identityFields != nil || policy.identityPresent || policy.identityLocation != nil ||
policy.recordOrder != nil || policy.recordPolicies != nil
}
func (assembly *assemblyAccumulator) setCollectionPolicy(destination Path, policy collectionPolicy) {
if assembly == nil || assembly.current == nil {
return
}
if assembly.collectionPolicyRetirement == nil {
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
}
assembly.current.collectionPolicies.set(destination, policy)
entry := assembly.current.collectionPolicies.collections[destination.String()]
if collectionPolicyHasRetirementState(entry.policy) {
assembly.collectionPolicyRetirement.set(entry)
} else {
assembly.collectionPolicyRetirement.remove(destination)
}
}
func (assembly *assemblyAccumulator) retireCollectionPoliciesAtOrBelow(ancestor Path) {
if assembly == nil || assembly.current == nil {
return
}
if assembly.collectionPolicyRetirement == nil {
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
}
assembly.collectionPolicyRetirement.retire(&assembly.current.collectionPolicies, ancestor)
}

View File

@@ -21,8 +21,8 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("policy diagnostics = %#v", diagnostics) t.Fatalf("policy diagnostics = %#v", diagnostics)
} }
if !resolved.declarationsPresent || !resolved.identityDeclarationPresent || !resolved.modeDeclarationPresent { if !resolved.modeDeclarationPresent {
t.Fatalf("declaration presence was lost: %#v", resolved) t.Fatalf("mode declaration was lost: %#v", resolved)
} }
wantFields := []string{"id", "namespace"} wantFields := []string{"id", "namespace"}
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) { if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
@@ -31,9 +31,6 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend { if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation) t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
} }
if len(resolved.declarations) != 3 || resolved.declarations[0].identityLocation == nil || resolved.declarations[2].location.Line == 0 {
t.Fatalf("declaration locations were not retained: %#v", resolved.declarations)
}
} }
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) { func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {

View File

@@ -7,6 +7,23 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// operation at that exact path. // operation at that exact path.
type collectionReplacementPaths map[string]bool type collectionReplacementPaths map[string]bool
type lowerCollectionPolicyView struct {
scope collectionPolicyScope
replacements collectionReplacementPaths
}
func (view lowerCollectionPolicyView) at(path Path) (collectionPolicy, bool) {
entry, found := view.scope.collections[path.String()]
if !found {
return collectionPolicy{}, false
}
policy := entry.policy
if view.replacements.contains(path, true) {
clearCollectionIdentity(&policy)
}
return policy, true
}
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool { func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
for length := 0; length <= len(path.segments); length++ { for length := 0; length <= len(path.segments); length++ {
prefix := Path{segments: path.segments[:length]} prefix := Path{segments: path.segments[:length]}
@@ -27,6 +44,26 @@ func lowerCollectionPoliciesForClaims(
declarations map[string][]sequenceDeclarationSpec, declarations map[string][]sequenceDeclarationSpec,
application sequencePolicy, application sequencePolicy,
) (collectionPolicyScope, collectionReplacementPaths) { ) (collectionPolicyScope, collectionReplacementPaths) {
replaced := collectionReplacementPathsForClaims(lower, claims, declarations, application)
// This materialization is used when inheritance creates an independently
// owned local scope. Strict tier resolution uses lowerCollectionPolicyView
// instead and keeps only its sparse current-scope result.
result := cloneCollectionPolicyScope(lower)
for key, entry := range result.collections {
if replaced.contains(entry.destination, true) {
clearCollectionIdentity(&entry.policy)
result.collections[key] = entry
}
}
return result, replaced
}
func collectionReplacementPathsForClaims(
lower collectionPolicyScope,
claims []compositionClaim,
declarations map[string][]sequenceDeclarationSpec,
application sequencePolicy,
) collectionReplacementPaths {
byPath := make(map[string]compositionClaim, len(claims)) byPath := make(map[string]compositionClaim, len(claims))
rootPath := "" rootPath := ""
if len(claims) != 0 { if len(claims) != 0 {
@@ -62,14 +99,7 @@ func lowerCollectionPoliciesForClaims(
replaced[key] = false replaced[key] = false
} }
} }
result := cloneCollectionPolicyScope(lower) return replaced
for key, entry := range result.collections {
if replaced.contains(entry.destination, true) {
clearCollectionIdentity(&entry.policy)
result.collections[key] = entry
}
}
return result, replaced
} }
func clearCollectionIdentity(policy *collectionPolicy) { func clearCollectionIdentity(policy *collectionPolicy) {

View File

@@ -2,7 +2,10 @@ package engine
import ( import (
"errors" "errors"
"fmt"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) { func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
@@ -109,3 +112,239 @@ func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *
} }
} }
} }
func TestAssemblyPolicyRetirementUsesSemanticPathSegments(t *testing.T) {
t.Parallel()
var policies collectionPolicyScope
slashPath := Keys("branch/name", "items")
separateKeysPath := Keys("branch", "name", "items")
numericKeyPath := Keys("items", "0", "named")
sequenceIndexPath := Keys("items").Index(0).Key("indexed")
policies.set(slashPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(separateKeysPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(numericKeyPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(sequenceIndexPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
assembly.retireCollectionPoliciesAtOrBelow(Keys("branch/name"))
assertRetirement := func(path Path, identityPresent bool) {
t.Helper()
policy, found := assembly.current.collectionPolicies.at(path)
if !found || policy.identityPresent != identityPresent {
t.Fatalf("policy at %s = %#v (found %t), want identity present %t", path, policy, found, identityPresent)
}
}
assertRetirement(slashPath, false)
assertRetirement(separateKeysPath, true)
assembly.retireCollectionPoliciesAtOrBelow(Keys("items", "0"))
assertRetirement(numericKeyPath, false)
assertRetirement(sequenceIndexPath, true)
assembly.retireCollectionPoliciesAtOrBelow(Keys("items").Index(0))
assertRetirement(sequenceIndexPath, false)
}
func TestAssemblyPolicyRetirementPrunesAndReindexesAroundModeOnlyEntries(t *testing.T) {
t.Parallel()
identityPath := Keys("catalog", "items")
modePath := Keys("catalog", "settings")
var policies collectionPolicyScope
policies.set(identityPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
policies.set(modePath, collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
retired, found := assembly.current.collectionPolicies.at(identityPath)
if !found || retired.identityPresent || !assembly.collectionPolicyRetirement.empty() {
t.Fatalf("first retirement left identity or stale index: %#v, index %#v", retired, assembly.collectionPolicyRetirement)
}
mode, found := assembly.current.collectionPolicies.at(modePath)
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
t.Fatalf("mode-only policy changed during retirement: %#v, found %t", mode, found)
}
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
newPath := Keys("catalog", "items", "nested")
assembly.setCollectionPolicy(newPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
if assembly.collectionPolicyRetirement.empty() {
t.Fatal("new identity-bearing policy was not reindexed")
}
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
newPolicy, found := assembly.current.collectionPolicies.at(newPath)
if !found || newPolicy.identityPresent || !assembly.collectionPolicyRetirement.empty() {
t.Fatalf("repeated ancestor retirement left new identity or stale index: %#v, index %#v", newPolicy, assembly.collectionPolicyRetirement)
}
mode, found = assembly.current.collectionPolicies.at(modePath)
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
t.Fatalf("repeated retirement changed retained mode: %#v, found %t", mode, found)
}
}
func TestDisjointScalarReplacementsRetainIndexedCollectionPolicies(t *testing.T) {
t.Parallel()
const policyCount = 128
const replacementCount = 32
var policies collectionPolicyScope
collectionEntries := make([]document.Entry, 0, policyCount)
for index := 0; index < policyCount; index++ {
key := fmt.Sprintf("collection-%03d", index)
path := Keys("collections", key)
policies.set(path, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
collectionEntries = append(collectionEntries, document.Entry{
Key: key, Value: &document.Node{Kind: document.Sequence},
})
}
baseScalars := make([]document.Entry, 0, replacementCount)
incomingScalars := make([]document.Entry, 0, replacementCount)
for index := 0; index < replacementCount; index++ {
key := fmt.Sprintf("scalar-%03d", index)
baseScalars = append(baseScalars, document.Entry{
Key: key, Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "previous", Value: &document.Node{Kind: document.Boolean, Text: "true"},
}}},
})
incomingScalars = append(incomingScalars, document.Entry{
Key: key, Value: &document.Node{Kind: document.String, Text: "replaced"},
})
}
base := &composedDocument{
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "collections", Value: &document.Node{Kind: document.Mapping, Mapping: collectionEntries}},
{Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: baseScalars}},
}},
collectionPolicies: policies,
origins: make(map[string]Origin),
}
incoming := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: incomingScalars},
}}}
assembly := newAssemblyAccumulator(base)
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
incoming, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, nil,
); len(diagnostics) != 0 {
t.Fatalf("apply disjoint scalar replacements: %#v", diagnostics)
}
for index := 0; index < policyCount; index++ {
path := Keys("collections", fmt.Sprintf("collection-%03d", index))
policy, found := assembly.current.collectionPolicies.at(path)
if !found || !policy.identityPresent || len(policy.identityFields) != 1 || policy.identityFields[0] != "id" {
t.Fatalf("disjoint replacement changed policy %s: %#v, found %t", path, policy, found)
}
}
for index := 0; index < replacementCount; index++ {
path := Keys("scalars", fmt.Sprintf("scalar-%03d", index))
value, found := documentAtPath(assembly.current.root, path)
if !found || value.Kind != document.String || value.Text != "replaced" {
t.Fatalf("scalar replacement at %s = %#v, found %t", path, value, found)
}
}
}
func TestDisjointTierOverlaysRetainNestedCollectionPolicies(t *testing.T) {
t.Parallel()
nestedOwner := newCollectionRecordPolicy()
nestedOwner.scope.set(Keys("prompts"), collectionPolicy{
identityFields: []string{"name"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
itemsPolicy := collectionPolicyWithRecords(collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
}, nestedOwner)
var policies collectionPolicyScope
policies.set(Keys("items"), itemsPolicy)
policies.set(Keys("keyOnly"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
})
policies.set(Keys("modeOnly"), collectionPolicy{
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
base := &composedDocument{
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "items", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
Kind: document.Mapping,
Mapping: []document.Entry{
{Key: "id", Value: &document.Node{Kind: document.String, Text: "one"}},
{Key: "prompts", Value: &document.Node{Kind: document.Sequence}},
},
}}}},
{Key: "keyOnly", Value: &document.Node{Kind: document.Sequence}},
{Key: "modeOnly", Value: &document.Node{Kind: document.Sequence}},
}},
collectionPolicies: policies,
origins: make(map[string]Origin),
}
assembly := newAssemblyAccumulator(base)
initialItems, found := assembly.current.collectionPolicies.at(Keys("items"))
if !found {
t.Fatal("initial item policy is missing")
}
initialRecord, found := initialItems.recordPolicyAt(0)
if !found {
t.Fatal("initial nested record owner is missing")
}
for index, key := range []string{"firstTier", "secondTier", "thirdTier"} {
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: key, Value: &document.Node{Kind: document.Boolean, Text: "true"},
}}}
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
); len(diagnostics) != 0 {
t.Fatalf("apply disjoint tier %d: %#v", index, diagnostics)
}
retainedItems, found := assembly.current.collectionPolicies.at(Keys("items"))
if !found || !retainedItems.identityPresent || len(retainedItems.identityFields) != 1 || retainedItems.identityFields[0] != "id" ||
!retainedItems.explicitModePresent || retainedItems.explicitMode != sequenceModeAppend {
t.Fatalf("tier %d changed retained item policy: %#v, found %t", index, retainedItems, found)
}
retainedRecord, found := retainedItems.recordPolicyAt(0)
if !found || retainedRecord.owner != initialRecord.owner {
t.Fatalf("tier %d replaced the nested record owner: %#v, found %t", index, retainedRecord, found)
}
nested, found := retainedRecord.scope.at(Keys("prompts"))
if !found || !nested.identityPresent || len(nested.identityFields) != 1 || nested.identityFields[0] != "name" ||
!nested.explicitModePresent || nested.explicitMode != sequenceModeAppend {
t.Fatalf("tier %d changed nested collection policy: %#v, found %t", index, nested, found)
}
keyOnly, found := assembly.current.collectionPolicies.at(Keys("keyOnly"))
if !found || !keyOnly.identityPresent || keyOnly.explicitModePresent {
t.Fatalf("tier %d changed key-only policy: %#v, found %t", index, keyOnly, found)
}
modeOnly, found := assembly.current.collectionPolicies.at(Keys("modeOnly"))
if !found || modeOnly.identityPresent || !modeOnly.explicitModePresent || modeOnly.explicitMode != sequenceModeAppend {
t.Fatalf("tier %d changed mode-only policy: %#v, found %t", index, modeOnly, found)
}
}
}
func TestOverlayNonSequenceClaimAtRetainedCollectionStillFails(t *testing.T) {
t.Parallel()
var policies collectionPolicyScope
policies.set(Keys("items"), collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
})
base := &composedDocument{
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "items", Value: &document.Node{Kind: document.Sequence},
}}},
collectionPolicies: policies,
origins: make(map[string]Origin),
}
assembly := newAssemblyAccumulator(base)
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "items", Value: &document.Node{Kind: document.String, Text: "not a sequence"},
}}}
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
); len(diagnostics) == 0 {
t.Fatal("non-sequence overlay at an append collection unexpectedly succeeded")
}
}

View File

@@ -43,12 +43,20 @@ type sequenceHistoryContribution struct {
// assemblyAccumulator owns one mutable working document. Acquired trees held by // assemblyAccumulator owns one mutable working document. Acquired trees held by
// history applications are separate from its root and remain unchanged. // history applications are separate from its root and remain unchanged.
type assemblyAccumulator struct { type assemblyAccumulator struct {
current *composedDocument current *composedDocument
index mappingIndex index mappingIndex
collectionPolicyRetirement *collectionPolicyRetirementIndex
} }
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator { func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
return &assemblyAccumulator{current: owned} policies := collectionPolicyScope{}
if owned != nil {
policies = owned.collectionPolicies
}
return &assemblyAccumulator{
current: owned,
collectionPolicyRetirement: newCollectionPolicyRetirementIndex(policies),
}
} }
// cloneComposedDocument detaches mutable composition and provenance state; // cloneComposedDocument detaches mutable composition and provenance state;
@@ -305,14 +313,10 @@ func resolveStrictCollectionPolicies(
valuePresent := make(map[string]bool) valuePresent := make(map[string]bool)
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations)) declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
for _, claim := range claims { for _, claim := range claims {
if len(lower.collections) != 0 {
valuePresent[claim.path.String()] = true
}
if claim.kind != document.Sequence {
continue
}
key := claim.path.String() key := claim.path.String()
if !seen[key] { valuePresent[key] = true
_, hasLower := lower.collections[key]
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
seen[key] = true seen[key] = true
paths = append(paths, claim.path.clone()) paths = append(paths, claim.path.clone())
} }
@@ -327,21 +331,18 @@ func resolveStrictCollectionPolicies(
} }
identityFieldsByPath := make(map[string][]string) identityFieldsByPath := make(map[string][]string)
effectiveLower := lower replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
if len(lower.collections) != 0 { lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy) var collectionPolicies collectionPolicyScope
}
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
var diagnostics []Diagnostic var diagnostics []Diagnostic
for _, path := range paths { for _, path := range paths {
key := path.String() key := path.String()
currentDeclarations := declarationsByPath[key] currentDeclarations := declarationsByPath[key]
lowerEntry, hasLower := effectiveLower.collections[key] lowerPolicyValue, hasLower := lowerView.at(path)
var lowerPolicy *collectionPolicy var lowerPolicy *collectionPolicy
if hasLower { if hasLower {
copy := cloneCollectionPolicy(lowerEntry.policy) lowerPolicy = &lowerPolicyValue
lowerPolicy = &copy
} }
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path) applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
@@ -358,7 +359,7 @@ func resolveStrictCollectionPolicies(
if hasLower && !valuePresent[key] { if hasLower && !valuePresent[key] {
// A declaration without a new value changes policy metadata but // A declaration without a new value changes policy metadata but
// leaves retained record ownership untouched. // leaves retained record ownership untouched.
policyForPath = cloneCollectionPolicy(lowerEntry.policy) policyForPath = cloneCollectionPolicy(lowerPolicyValue)
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields) policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
policyForPath.identityPresent = resolution.policy.identityPresent policyForPath.identityPresent = resolution.policy.identityPresent
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation) policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
@@ -367,7 +368,7 @@ func resolveStrictCollectionPolicies(
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation) policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
} }
if collectionPolicies.collections == nil { if collectionPolicies.collections == nil {
collectionPolicies.collections = make(map[string]collectionPolicyEntry) collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
} }
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath} collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
} }
@@ -483,21 +484,6 @@ func allMappingClaims(claims []compositionClaim) bool {
return true return true
} }
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
for _, incomingEntry := range incoming.Mapping {
position := index.find(target, incomingEntry.Key)
if position < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
index.append(target, cloned)
continue
}
// Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
}
}
func documentEntryIndex(entries []document.Entry, key string) int { func documentEntryIndex(entries []document.Entry, key string) int {
for index := range entries { for index := range entries {
if entries[index].Key == key { if entries[index].Key == key {

View File

@@ -489,6 +489,96 @@ func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
} }
} }
func TestComposeDocumentsReturnsSparseCollectionPolicies(t *testing.T) {
itemsPath := Keys("items")
unrelatedPath := Keys("unrelated")
var lower collectionPolicyScope
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
unrelated := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
unrelated.setRecordPolicy(newCollectionRecordPolicy())
lower.set(unrelatedPath, unrelated)
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, nil,
parseCompositionDocument(t, "incoming", "items: [{id: current}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose incoming sequence: %#v", diagnostics)
}
if len(composed.collectionPolicies.collections) != 1 {
t.Fatalf("incoming collection policies = %#v; want only touched destinations", composed.collectionPolicies.collections)
}
if policy, found := composed.collectionPolicies.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
t.Fatalf("resolved incoming policy = %#v, found %t", policy, found)
}
if _, found := composed.collectionPolicies.at(unrelatedPath); found {
t.Fatal("incoming composition copied an unrelated lower policy")
}
if policy, found := lower.at(unrelatedPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"name"}) || len(policy.recordOrder) != 1 {
t.Fatalf("borrowed lower policy changed during composition: %#v, found %t", policy, found)
}
composed.collectionPolicies.collections[itemsPath.String()].policy.identityFields[0] = "changed"
if policy, _ := lower.at(itemsPath); !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
t.Fatalf("incoming policy shares identity storage with lower scope: %#v", policy)
}
}
func TestStrictCompositionMasksLowerIdentityForReplacedAncestor(t *testing.T) {
itemsPath := Keys("catalog", "items")
var lower collectionPolicyScope
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
declaration := sequenceDeclarationSpec{destination: Keys("catalog"), mode: sequenceModeReplace, modePresent: true}
_, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "catalog: {items: [{name: first}]}\n"),
parseCompositionDocument(t, "source-2", "catalog: {items: [{name: second}]}\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/catalog/items" {
t.Fatalf("strict peers used identity masked by replaced ancestor: %#v", diagnostics)
}
if policy, found := lower.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
t.Fatalf("strict resolution mutated borrowed lower identity: %#v, found %t", policy, found)
}
}
func TestComposeDocumentsDeclarationWithoutValueRetainsOwnedRecordPolicy(t *testing.T) {
path := Keys("items")
nestedPath := Keys("prompts")
var nested collectionPolicyScope
nested.set(nestedPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
record := collectionRecordPolicy{owner: newCollectionRecordPolicy().owner, scope: nested}
lowerPolicy := collectionPolicy{
identityFields: []string{"id"}, identityPresent: true,
explicitMode: sequenceModeAppend, explicitModePresent: true,
}
lowerPolicy.setRecordPolicy(record)
var lower collectionPolicyScope
lower.set(path, lowerPolicy)
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "metadata", "other: value\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose declaration without a value: %#v", diagnostics)
}
resolved, found := composed.collectionPolicies.at(path)
if !found || !resolved.identityPresent || !reflect.DeepEqual(resolved.identityFields, []string{"id"}) || resolved.explicitMode != sequenceModeReplace || !resolved.explicitModePresent {
t.Fatalf("declaration-only result changed retained collection policy: %#v, found %t", resolved, found)
}
if len(resolved.recordOrder) != 1 || len(resolved.recordPolicies) != 1 {
t.Fatalf("declaration-only result lost record ownership: %#v", resolved)
}
owner := resolved.recordOrder[0]
resolved.recordPolicies[owner].collections[nestedPath.String()].policy.identityFields[0] = "changed"
retained, _ := lower.at(path)
if nestedPolicy, found := retained.recordPolicies[owner].at(nestedPath); !found || !reflect.DeepEqual(nestedPolicy.identityFields, []string{"name"}) {
t.Fatalf("declaration-only result borrowed nested record policy: %#v, found %t", nestedPolicy, found)
}
}
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) { func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
path := Keys("records") path := Keys("records")
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec { declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {

View File

@@ -43,9 +43,10 @@ func FuzzCompositionAndOverlay(f *testing.F) {
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 { if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
t.Fatalf("composition order changed effective values: %#v", differences) t.Fatalf("composition order changed effective values: %#v", differences)
} }
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"}) if mutateExistingDocumentDescendant(composed.root) {
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) { if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition result aliases an input document") t.Fatal("composition result aliases an input document")
}
} }
} }
@@ -60,9 +61,41 @@ func FuzzCompositionAndOverlay(f *testing.F) {
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) { if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay mutated an input document") t.Fatal("overlay mutated an input document")
} }
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"}) if mutateExistingDocumentDescendant(overlaid.root) {
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) { if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay result aliases an input document") t.Fatal("overlay result aliases an input document")
}
} }
}) })
} }
func mutateExistingDocumentDescendant(node *document.Node) bool {
if node == nil {
return false
}
// Empty containers have no existing element for the alias probe.
switch node.Kind {
case document.Mapping:
if len(node.Mapping) == 0 {
return false
}
entry := &node.Mapping[0]
if mutateExistingDocumentDescendant(entry.Value) {
return true
}
entry.Key += "-fuzz-detachment"
return true
case document.Sequence:
if len(node.Sequence) == 0 {
return false
}
if mutateExistingDocumentDescendant(node.Sequence[0]) {
return true
}
node.Sequence[0].Text += "fuzz-detachment"
return true
default:
node.Text += "fuzz-detachment"
return true
}
}

View File

@@ -22,7 +22,6 @@ type decodeIssue struct {
} }
type inputPresence struct { type inputPresence struct {
null bool
location document.Location location document.Location
} }
@@ -91,19 +90,30 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
} }
} }
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
var elementBase reflect.Value terminal, ok := terminalDescriptor(descriptor)
if base.IsNil() { if !ok {
elementBase = reflect.Zero(descriptor.typeOf.Elem()) return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration type cannot represent a non-null value")}
} else {
elementBase = base.Elem()
} }
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context) var pointers []*typeDescriptor
currentBase := base
for current := descriptor; current != terminal; current = current.element {
pointers = append(pointers, current)
if currentBase.IsNil() {
currentBase = reflect.Zero(current.typeOf.Elem())
} else {
currentBase = currentBase.Elem()
}
}
decoded, issues := decodeValue(node, currentBase, terminal, path, schema, context)
if len(issues) != 0 { if len(issues) != 0 {
return reflect.Value{}, issues return reflect.Value{}, issues
} }
result := reflect.New(descriptor.typeOf.Elem()) for i := len(pointers) - 1; i >= 0; i-- {
result.Elem().Set(decoded) result := reflect.New(pointers[i].typeOf.Elem())
return result, nil result.Elem().Set(decoded)
decoded = result
}
return decoded, nil
} }
switch descriptor.kind { switch descriptor.kind {
@@ -392,10 +402,11 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
} }
func acceptsMapping(descriptor *typeDescriptor) bool { func acceptsMapping(descriptor *typeDescriptor) bool {
for descriptor != nil && descriptor.kind == descriptorPointer { terminal, ok := terminalDescriptor(descriptor)
descriptor = descriptor.element if !ok {
return false
} }
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic) return terminal.kind == descriptorStruct || terminal.kind == descriptorMap || terminal.kind == descriptorDynamic
} }
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) { func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
@@ -422,7 +433,7 @@ func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Val
} }
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) { func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location} presence[path.String()] = inputPresence{location: node.Location}
switch node.Kind { switch node.Kind {
case document.Mapping: case document.Mapping:
for _, entry := range node.Mapping { for _, entry := range node.Mapping {

View File

@@ -11,33 +11,13 @@ var (
errDestinationLimit = errors.New("configkit: destination exceeds document limits") errDestinationLimit = errors.New("configkit: destination exceeds document limits")
) )
type placementMode uint8 type PlacementMode uint8
const ( const (
placementModeMapping placementMode = iota PlacementModeMapping PlacementMode = iota
placementModeSequenceItem PlacementModeSequenceItem
) )
// PlacementModeFromFacade preserves root-owned numeric values until preflight
// can reject unsupported modes.
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
// PlacementModeForFacade converts private source evidence to the root-owned mode.
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
// InputWithPlacement copies the root-owned mode into a private engine input.
func InputWithPlacement(input Input, value uint8) Input {
input.placementMode = placementMode(value)
return input
}
// OptionsWithPlacement copies root-owned controls into private engine options.
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
options.destination = destination.clone()
options.placementMode = placementMode(value)
return options
}
func normalizeDestination(path Path) (Path, error) { func normalizeDestination(path Path) (Path, error) {
if !path.valid() { if !path.valid() {
return Path{}, errInvalidDestination return Path{}, errInvalidDestination
@@ -74,26 +54,26 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
// placeDocument checks all per-document bounds before allocating destination // placeDocument checks all per-document bounds before allocating destination
// wrappers. Aggregate accounting remains the caller's responsibility. // wrappers. Aggregate accounting remains the caller's responsibility.
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) { func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes) return placeDocumentWithMode(body, destination, PlacementModeMapping, sourceRoot, maxDepth, maxNodes)
} }
// placeDocumentWithMode places a source mapping beneath its literal destination. // placeDocumentWithMode places a source mapping beneath its literal destination.
// Sequence-item placement adds one sequence containing the complete mapping. // Sequence-item placement adds one sequence containing the complete mapping.
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) { func placeDocumentWithMode(body *document.Node, destination Path, mode PlacementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
normalized, err := normalizeDestination(destination) normalized, err := normalizeDestination(destination)
if err != nil { if err != nil {
return Path{}, nil, err return Path{}, nil, err
} }
if mode != placementModeMapping && mode != placementModeSequenceItem { if mode != PlacementModeMapping && mode != PlacementModeSequenceItem {
return Path{}, nil, errInvalidDestination return Path{}, nil, errInvalidDestination
} }
if mode == placementModeSequenceItem && len(normalized.segments) == 0 { if mode == PlacementModeSequenceItem && len(normalized.segments) == 0 {
return Path{}, nil, errInvalidDestination return Path{}, nil, errInvalidDestination
} }
if body == nil { if body == nil {
return normalized, nil, nil return normalized, nil, nil
} }
if mode == placementModeSequenceItem && body.Kind != document.Mapping { if mode == PlacementModeSequenceItem && body.Kind != document.Mapping {
return normalized, nil, errInvalidDestination return normalized, nil, errInvalidDestination
} }
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) { if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
@@ -101,7 +81,7 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
} }
placed := body placed := body
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
placed = &document.Node{ placed = &document.Node{
Kind: document.Sequence, Location: sourceRoot, Kind: document.Sequence, Location: sourceRoot,
Sequence: []*document.Node{placed}, Sequence: []*document.Node{placed},
@@ -125,20 +105,20 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
// destinationWithinLimits expects a normalized mapping-key destination. // destinationWithinLimits expects a normalized mapping-key destination.
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool { func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes) return destinationWithinLimitsWithMode(body, destination, PlacementModeMapping, maxDepth, maxNodes)
} }
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool { func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode PlacementMode, maxDepth, maxNodes int) bool {
if body == nil { if body == nil {
return true return true
} }
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) { if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != PlacementModeMapping && mode != PlacementModeSequenceItem) {
return false return false
} }
segments := len(destination.segments) segments := len(destination.segments)
sequenceNodes := 0 sequenceNodes := 0
sequenceDepth := 0 sequenceDepth := 0
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
if segments == 0 { if segments == 0 {
return false return false
} }
@@ -160,9 +140,9 @@ func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode
return countInputNodes(body) <= maxNodes-wrapperNodes return countInputNodes(body) <= maxNodes-wrapperNodes
} }
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int { func destinationWrapperNodeCountForMode(destination Path, mode PlacementMode) int {
count := destinationWrapperNodeCount(destination) count := destinationWrapperNodeCount(destination)
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
count++ count++
} }
return count return count

View File

@@ -34,17 +34,17 @@ func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta} accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{ acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: empty, destination: destination, source: empty, destination: destination,
}}}, 1, nil, accounting) }}}, 1, nil, accounting)
if test.wantError { if test.wantError {
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics) t.Fatalf("over-budget placement = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge { if acquired == nil || len(diagnostics) != 0 || acquired.inputNodes != test.wantCharge {
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics) t.Fatalf("bounded placement = %#v, %#v", acquired, diagnostics)
} }
}) })
} }
@@ -62,9 +62,9 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one")) perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
charge := len(inputs) * perInput charge := len(inputs) * perInput
accounting := &importAccounting{nodes: limits.nodes - charge} accounting := &importAccounting{nodes: limits.nodes - charge}
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting) acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge { if len(diagnostics) != 0 || acquired == nil || accounting.nodes != limits.nodes || acquired.inputNodes != charge {
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge) t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", acquired, diagnostics, accounting, charge)
} }
} }
@@ -92,9 +92,9 @@ id: parent
childDestination := appendDestination(destination, Keys("children")) childDestination := appendDestination(destination, Keys("children"))
otherDestination := Keys("other") otherDestination := Keys("other")
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) + wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) + destinationWrapperNodeCountForMode(destination, PlacementModeSequenceItem) +
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) + destinationWrapperNodeCountForMode(childDestination, PlacementModeSequenceItem) +
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem) destinationWrapperNodeCountForMode(otherDestination, PlacementModeSequenceItem)
limits := defaultImportLimits() limits := defaultImportLimits()
for _, test := range []struct { for _, test := range []struct {
@@ -107,22 +107,22 @@ id: parent
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta} accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{ acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem, source: File(mainPath), imports: true, destination: destination, placementMode: PlacementModeSequenceItem,
}, { }, {
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem, source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: PlacementModeSequenceItem,
}}}, 1, nil, accounting) }}}, 1, nil, accounting)
if test.wantError { if test.wantError {
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics) t.Fatalf("under-budget nested item glob = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 { if acquired == nil || len(diagnostics) != 0 || len(acquired.sources) != 3 {
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics) t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", acquired, acquired.sources, diagnostics)
} }
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta { if acquired.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta) t.Fatalf("nested item node charge = %d/%d, want %d/%d", acquired.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
} }
}) })
} }
@@ -160,7 +160,7 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta} accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{ composition, sources, _, diagnostics := testComposeUnderlayGroup(loadGroup{inputs: []loadInput{{
source: mainSource, imports: true, destination: destination, source: mainSource, imports: true, destination: destination,
}}}, 1, nil, accounting) }}}, 1, nil, accounting)
if test.wantError { if test.wantError {
@@ -217,22 +217,26 @@ func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
currentLimits.nodes = wantNodes + test.delta currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{} accounting := &importAccounting{}
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil) request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting) acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
if test.wantError { if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics) t.Fatalf("below-bound relative placement = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 { if len(diagnostics) != 0 || acquired == nil || len(sources) != 2 {
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics) t.Fatalf("bounded relative placement = %#v, %#v, %#v", acquired, sources, diagnostics)
} }
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes { if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes) t.Fatalf("relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
} }
if sources[1].Destination.String() != importDestination.String() { if sources[1].Destination.String() != importDestination.String() {
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String()) t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
} }
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
if len(diagnostics) != 0 {
t.Fatalf("compose relative placement: %#v", diagnostics)
}
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty relative import body = %#v, present %v", node, present) t.Fatalf("empty relative import body = %#v, present %v", node, present)
} }
@@ -279,24 +283,28 @@ func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
currentLimits.nodes = wantNodes + test.delta currentLimits.nodes = wantNodes + test.delta
accounting := &importAccounting{} accounting := &importAccounting{}
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil) request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
composed, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting) acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
if test.wantError { if test.wantError {
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded { if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics) t.Fatalf("below-bound glob placement = %#v, %#v", acquired, diagnostics)
} }
return return
} }
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 { if len(diagnostics) != 0 || acquired == nil || len(sources) != len(fragmentPaths)+1 {
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics) t.Fatalf("bounded glob placement = %#v, %#v, %#v", acquired, sources, diagnostics)
} }
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes { if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes) t.Fatalf("glob relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
} }
for index, source := range sources[1:] { for index, source := range sources[1:] {
if source.Destination.String() != importDestination.String() { if source.Destination.String() != importDestination.String() {
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String()) t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
} }
} }
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
if len(diagnostics) != 0 {
t.Fatalf("compose glob placement: %#v", diagnostics)
}
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty glob contributions = %#v, present %v", node, present) t.Fatalf("empty glob contributions = %#v, present %v", node, present)
} }
@@ -365,13 +373,17 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
limits := defaultImportLimits() limits := defaultImportLimits()
accounting := &importAccounting{} accounting := &importAccounting{}
request := testImportRequest(File(mainPath), nil, main, destination, nil, nil) request := testImportRequest(File(mainPath), nil, main, destination, nil, nil)
composed, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting) acquired, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 { if len(diagnostics) != 0 || acquired == nil || len(sources) != 1 {
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics) t.Fatalf("absent optional import = %#v, %#v, %#v", acquired, sources, diagnostics)
} }
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination) wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes { if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes) t.Fatalf("absent optional node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
}
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, acquired, sources)
if len(diagnostics) != 0 {
t.Fatalf("compose absent optional import: %#v", diagnostics)
} }
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("empty entry body = %#v, present %v", node, present) t.Fatalf("empty entry body = %#v, present %v", node, present)
@@ -384,12 +396,12 @@ func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
t.Parallel() t.Parallel()
accounting := &importAccounting{} accounting := &importAccounting{}
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{ acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
{source: NoSource(), destination: Keys("absent")}, {source: NoSource(), destination: Keys("absent")},
{source: Mapping("root", map[string]any{})}, {source: Mapping("root", map[string]any{})},
}}, 1, nil, accounting) }}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 { if len(diagnostics) != 0 || acquired == nil || len(acquired.sources) != 1 || accounting.nodes != 1 || acquired.inputNodes != 1 {
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting) t.Fatalf("root and absent placement = %#v, diagnostics %#v, accounting %#v", acquired, diagnostics, accounting)
} }
} }
@@ -446,7 +458,7 @@ func testAssembleLayeredInput[T any](request loadRequest[T], input assembledInpu
accounting = &importAccounting{} accounting = &importAccounting{}
} }
policy := acquisitionPolicy{ policy := acquisitionPolicy{
importDefaults: snapshotImportDefaults(request.importDefaults), importDefaults: request.importDefaults,
sequencePolicy: request.sequencePolicy, sequencePolicy: request.sequencePolicy,
importLimits: defaultImportLimits(), importLimits: defaultImportLimits(),
} }

View File

@@ -201,7 +201,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}}, sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
} }
item := func(label string, value map[string]any) Input { item := func(label string, value map[string]any) Input {
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem} return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem}
} }
options := func(inputs ...Input) LayeredOptions[map[string]any] { options := func(inputs ...Input) LayeredOptions[map[string]any] {
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}} return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}

View File

@@ -22,11 +22,11 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
t.Parallel() t.Parallel()
loaded, err := LoadLayered[destinationItemConfig]( loaded, err := LoadLayered[destinationItemConfig](
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
testSecretBinding(), testSecretBinding(),
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"), Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
placementMode: placementModeSequenceItem, PlacementMode: PlacementModeSequenceItem,
}}}}}, }}}}},
) )
if err != nil { if err != nil {
@@ -40,7 +40,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup") t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved { if len(sources) != 1 || sources[0].placementMode != PlacementModeSequenceItem || sources[0].destinationUnresolved {
t.Fatalf("source placement evidence = %#v", sources) t.Fatalf("source placement evidence = %#v", sources)
} }
if got := sources[0].Destination.String(); got != "/catalog/entries" { if got := sources[0].Destination.String(); got != "/catalog/entries" {
@@ -48,7 +48,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
} }
absent, err := LoadLayered[destinationItemConfig]( absent, err := LoadLayered[destinationItemConfig](
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
testSecretBinding(), testSecretBinding(),
) )
if err != nil { if err != nil {
@@ -64,17 +64,17 @@ func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
mode placementMode mode PlacementMode
path Path path Path
}{ }{
{name: "root item", mode: placementModeSequenceItem}, {name: "root item", mode: PlacementModeSequenceItem},
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")}, {name: "unknown mode", mode: PlacementMode(255), path: Keys("catalog")},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
reader := bytes.NewBufferString("value: unused\n") reader := bytes.NewBufferString("value: unused\n")
_, err := LoadLayered[destinationItemConfig]( _, err := LoadLayered[destinationItemConfig](
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode}, Input{Source: Reader("reader", reader), Destination: test.path, PlacementMode: test.mode},
testSecretBinding(), testSecretBinding(),
) )
if err == nil || reader.Len() == 0 { if err == nil || reader.Len() == 0 {
@@ -93,7 +93,7 @@ func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
loaded, err := Load[destinationItemConfig]( loaded, err := Load[destinationItemConfig](
Mapping("ordinary", map[string]any{}), testSecretBinding(), Mapping("ordinary", map[string]any{}), testSecretBinding(),
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}, Options[destinationItemConfig]{Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
) )
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)

View File

@@ -54,7 +54,7 @@ func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32) explicitPath, explicit, err := placeDocumentWithMode(body, destination, PlacementModeMapping, body.Location, 5, 32)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -235,7 +235,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
root := document.Location{SourceID: "item", Line: 1, Column: 1} root := document.Location{SourceID: "item", Line: 1, Column: 1}
body := &document.Node{Kind: document.Mapping, Location: root} body := &document.Node{Kind: document.Mapping, Location: root}
destination := Keys("catalog", "entries") destination := Keys("catalog", "entries")
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6) placedPath, placed, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, 3, 6)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -253,13 +253,13 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
t.Fatal("synthetic sequence or authored mapping lost source coordinates") t.Fatal("synthetic sequence or authored mapping lost source coordinates")
} }
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) { if _, _, err := placeDocumentWithMode(body, Path{}, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
t.Fatalf("root item placement error = %v, want invalid destination", err) t.Fatalf("root item placement error = %v, want invalid destination", err)
} }
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) { if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err) t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
} }
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) { if _, _, err := placeDocumentWithMode(body, destination, PlacementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
t.Fatalf("unknown placement mode error = %v, want invalid destination", err) t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
} }
@@ -275,7 +275,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes) _, _, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, test.depth, test.nodes)
if test.wantAccept && err != nil { if test.wantAccept && err != nil {
t.Fatalf("exact bounds rejected: %v", err) t.Fatalf("exact bounds rejected: %v", err)
} }

View File

@@ -19,11 +19,9 @@ const (
) )
type rootDirectiveExtraction struct { type rootDirectiveExtraction struct {
body *document.Node body *document.Node
imports []importDeclarationSpec imports []importDeclarationSpec
sequences []sequenceDeclarationSpec sequences []sequenceDeclarationSpec
importsPresent bool
sequencesPresent bool
} }
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) { func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
@@ -74,7 +72,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
fieldPath := directivePath.Key(entry.Key) fieldPath := directivePath.Key(entry.Key)
switch entry.Key { switch entry.Key {
case "imports": case "imports":
extracted.importsPresent = true
if context == rootDirectiveContextOverlay { if context == rootDirectiveContextOverlay {
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value)) diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
continue continue
@@ -87,7 +84,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
extracted.imports = append(extracted.imports, parsed...) extracted.imports = append(extracted.imports, parsed...)
diagnostics = append(diagnostics, issues...) diagnostics = append(diagnostics, issues...)
case "sequences": case "sequences":
extracted.sequencesPresent = true
if context == rootDirectiveContextImportsOnly { if context == rootDirectiveContextImportsOnly {
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value)) diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
continue continue

View File

@@ -255,7 +255,7 @@ func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics) t.Fatalf("parse diagnostics = %#v", diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("policy diagnostics = %#v", diagnostics) t.Fatalf("policy diagnostics = %#v", diagnostics)
} }

View File

@@ -34,7 +34,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -43,7 +43,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("selection diagnostics = %#v", diagnostics) t.Fatalf("selection diagnostics = %#v", diagnostics)
} }
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 3 { if len(selection.candidates) != 3 {
t.Fatalf("selection = %#v, want three unioned matches", selection) t.Fatalf("selection = %#v, want three unioned matches", selection)
} }
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" { if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
@@ -82,7 +82,7 @@ func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -141,7 +141,7 @@ func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -173,7 +173,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -188,7 +188,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics = resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -218,7 +218,7 @@ func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -254,7 +254,7 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
yaml string yaml string
defaults importDefaults defaults ImportDefaults
wantPaths []string wantPaths []string
}{ }{
{ {
@@ -280,13 +280,13 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
{ {
name: "explicit false overrides application recursion", name: "explicit false overrides application recursion",
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n", yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
defaults: importDefaults{recursive: boolPointer(true)}, defaults: ImportDefaults{Recursive: boolPointer(true)},
wantPaths: []string{"direct.yaml"}, wantPaths: []string{"direct.yaml"},
}, },
{ {
name: "application default enables recursion for depth", name: "application default enables recursion for depth",
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n", yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
defaults: importDefaults{recursive: boolPointer(true)}, defaults: ImportDefaults{Recursive: boolPointer(true)},
wantPaths: []string{"child.yaml", "direct.yaml"}, wantPaths: []string{"child.yaml", "direct.yaml"},
}, },
{ {
@@ -347,13 +347,13 @@ func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
accounting := &importAccounting{} accounting := &importAccounting{}
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting) selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
if len(diagnostics) != 0 || selection.outcome != importSelectionNone || len(selection.candidates) != 0 { if len(diagnostics) != 0 || len(selection.candidates) != 0 {
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics) t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
} }
if accounting.entries != 5 { if accounting.entries != 5 {

View File

@@ -28,7 +28,7 @@ settings: {main: selected}
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n") importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
search := SearchConfig("app", SearchDirectory(root)) search := SearchConfig("app", SearchDirectory(root))
options := Options[discoveryLoadConfig]{ options := Options[discoveryLoadConfig]{
Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"), Imports: true, Filesystem: &FilesystemContext{Root: root}, Destination: Keys("service"),
} }
searched, err := testLoad(search, options) searched, err := testLoad(search, options)
if err != nil { if err != nil {
@@ -172,7 +172,7 @@ func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
load func(Source) (*Loaded[discoveryLoadConfig], error) load func(Source) (*Loaded[discoveryLoadConfig], error)
}{ }{
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) { {"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{destination: Keys("settings")}) return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Destination: Keys("settings")})
}}, }},
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) { {"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{ return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{

View File

@@ -0,0 +1,160 @@
package engine
import (
"errors"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type unsupportedDynamicRoot struct {
Child unsupportedDynamicChild `yaml:"child"`
Invalid chan int `yaml:"invalid"`
}
type unsupportedDynamicChild struct {
Parent *unsupportedDynamicRoot `yaml:"parent"`
}
type secretDynamicRoot struct {
Nested secretDynamicNested `yaml:"nested"`
}
type secretDynamicNested struct {
Token Secret `yaml:"token"`
}
type dynamicTransactionConfig struct {
First any `yaml:"first"`
Second any `yaml:"second"`
Good any `yaml:"good"`
}
func TestDynamicDescriptorFailuresRollBackProvisionalTypes(t *testing.T) {
called := false
result, err := testLoad[dynamicTransactionConfig](NoSource(), Options[dynamicTransactionConfig]{Definition: Definition[dynamicTransactionConfig]{
Resolve: func(config *dynamicTransactionConfig, resolution *Resolution) error {
called = true
Normalize(resolution, Keys("first"), &config.First, any((*unsupportedDynamicRoot)(nil)), "try typed nil")
Normalize(resolution, Keys("second"), &config.Second, any(&unsupportedDynamicRoot{}), "try non-nil")
Normalize(resolution, Keys("good"), &config.Good, any(map[string]string{"valid": "value"}), "continue with supported value")
return nil
},
}})
var loadErr *LoadError
if !called || result != nil || !errors.As(err, &loadErr) {
t.Fatalf("result = %#v, called=%v, error=%v", result, called, err)
}
paths := make(map[string]Code)
for _, diagnostic := range loadErr.Diagnostics() {
paths[diagnostic.Path.String()] = diagnostic.Code
}
for _, path := range []string{"/first", "/second"} {
if paths[path] != CodeResolveInvalidValue {
t.Errorf("diagnostic at %s = %q, want safe invalid-value failure", path, paths[path])
}
}
schema := mustSchemaFor[dynamicTransactionConfig](t)
operation := valueOperation{schema: schema}
prior, issue := operation.dynamicDescriptor(reflect.TypeFor[map[string]string](), Keys("prior"))
if issue != nil {
t.Fatalf("prior valid dynamic descriptor issue = %#v", issue)
}
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("failure")); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("unsupported dynamic descriptor issue = %#v", issue)
}
if _, approved := operation.approvedDynamic[reflect.TypeFor[unsupportedDynamicRoot]()]; approved {
t.Fatal("unsupported dynamic descriptor was cached as eligible")
}
if schema.types[reflect.TypeFor[map[string]string]()] != prior {
t.Fatal("failed build removed a previously completed descriptor")
}
for _, current := range []reflect.Type{
reflect.TypeFor[unsupportedDynamicRoot](),
reflect.TypeFor[unsupportedDynamicChild](),
reflect.TypeFor[*unsupportedDynamicRoot](),
} {
if _, exists := schema.types[current]; exists {
t.Errorf("failed build retained provisional descriptor for %v", current)
}
}
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("again")); issue == nil || issue.path.String() != "/again" {
t.Fatalf("repeated failure issue = %#v", issue)
}
secretSchema, schemaIssue := buildSchema(reflect.TypeFor[dynamicTransactionConfig](), testSecretBinding())
if schemaIssue != nil {
t.Fatalf("secret schema issue = %#v", schemaIssue)
}
secretOperation := valueOperation{schema: secretSchema}
if _, issue := secretOperation.dynamicDescriptor(reflect.TypeFor[secretDynamicRoot](), Keys("secret")); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic secret eligibility issue = %#v", issue)
}
if len(secretOperation.approvedDynamic) != 0 {
t.Fatalf("secret-bearing dynamic descriptor was cached as eligible: %#v", secretOperation.approvedDynamic)
}
for _, current := range []reflect.Type{reflect.TypeFor[secretDynamicRoot](), reflect.TypeFor[secretDynamicNested](), reflect.TypeFor[Secret]()} {
if _, exists := secretSchema.types[current]; exists {
t.Errorf("secret eligibility failure retained provisional descriptor for %v", current)
}
}
}
type wideDynamicEligibilityValue struct {
Alpha string
Bravo string
Charlie string
Delta string
Echo string
Foxtrot string
Golf string
Hotel string
India string
Juliet string
Kilo string
Lima string
Mike string
November string
Oscar string
Papa string
}
type repeatedNilDynamicEligibilityConfig struct {
Values []any
}
func TestDynamicEligibilityIsReusedWithinProjectionOperation(t *testing.T) {
schema, schemaIssue := buildSchema(reflect.TypeFor[repeatedNilDynamicEligibilityConfig](), testSecretBinding())
if schemaIssue != nil {
t.Fatalf("schema issue = %#v", schemaIssue)
}
values := make([]any, 32)
for index := range values {
values[index] = (*wideDynamicEligibilityValue)(nil)
}
configuration := repeatedNilDynamicEligibilityConfig{Values: values}
operation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
node, issue := operation.project(reflect.ValueOf(configuration), schema.root, Path{}, 0)
if issue != nil {
t.Fatalf("repeated typed-nil projection issue = %#v", issue)
}
if len(node.Mapping) != 1 || len(node.Mapping[0].Value.Sequence) != len(values) {
t.Fatalf("projected repeated values = %#v", node)
}
for index, child := range node.Mapping[0].Value.Sequence {
if child.Kind != document.Null {
t.Errorf("projected value %d kind = %v, want null", index, child.Kind)
}
}
current := reflect.TypeFor[*wideDynamicEligibilityValue]()
if len(operation.approvedDynamic) != 1 || operation.approvedDynamic[current] == nil ||
operation.approvedDynamic[current] != schema.types[current] {
t.Fatalf("operation eligibility cache = %#v", operation.approvedDynamic)
}
otherOperation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
if len(otherOperation.approvedDynamic) != 0 {
t.Fatalf("dynamic eligibility leaked into another operation: %#v", otherOperation.approvedDynamic)
}
}

View File

@@ -308,12 +308,18 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) { func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
leaf := &document.Node{Kind: document.String, Text: "value"} leaf := &document.Node{Kind: document.String, Text: "value"}
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}} root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
applications := []historyApplication{{layer: LayerBase, value: root}} inspection := &Inspection{
if !historyNodesWithin(2, root, root, root, applications, nil) { baseline: root, input: root, final: root,
applications: []historyApplication{{layer: LayerBase, value: root}},
}
counter := newRetainedNodeCounter(2)
if !addInspectionHistory(counter, inspection) || counter.count != 2 {
t.Fatal("structurally shared history was counted more than once") t.Fatal("structurally shared history was counted more than once")
} }
other := document.Clone(root) other := document.Clone(root)
if historyNodesWithin(3, root, other, root, applications, nil) { inspection.input = other
counter = newRetainedNodeCounter(3)
if addInspectionHistory(counter, inspection) {
t.Fatal("distinct retained nodes did not consume the budget") t.Fatal("distinct retained nodes did not consume the budget")
} }
} }

View File

@@ -141,40 +141,59 @@ func (a *filesystemAccess) open(name string) (fs.File, error) {
return a.root.Open(relative) return a.root.Open(relative)
} }
type directoryReadError struct {
opened bool
err error
}
func (e *directoryReadError) Error() string { return "directory could not be read" }
func (e *directoryReadError) Unwrap() error { return e.err }
func missingDirectoryOpen(err error) bool {
var failure *directoryReadError
return errors.As(err, &failure) && !failure.opened && errors.Is(err, fs.ErrNotExist)
}
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error { func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
file, err := a.open(name) file, err := a.open(name)
if err != nil { if err != nil {
return err return &directoryReadError{err: err}
}
failure := func(err error) error {
if err == nil {
return nil
}
return &directoryReadError{opened: true, err: err}
} }
if reader, ok := file.(fs.ReadDirFile); ok { if reader, ok := file.(fs.ReadDirFile); ok {
for { for {
entries, readErr := reader.ReadDir(globReadBatch) entries, readErr := reader.ReadDir(globReadBatch)
for _, entry := range entries { for _, entry := range entries {
if !visit(entry) { if !visit(entry) {
return file.Close() return failure(file.Close())
} }
} }
if errors.Is(readErr, io.EOF) { if errors.Is(readErr, io.EOF) {
return file.Close() return failure(file.Close())
} }
if readErr != nil { if readErr != nil {
_ = file.Close() _ = file.Close()
return readErr return failure(readErr)
} }
if len(entries) == 0 { if len(entries) == 0 {
_ = file.Close() _ = file.Close()
return io.ErrNoProgress return failure(io.ErrNoProgress)
} }
} }
} }
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
return err return failure(err)
} }
// fs.ReadDir provides the standard fallback for implementations whose opened // fs.ReadDir provides the standard fallback for implementations whose opened
// files do not implement fs.ReadDirFile. It may allocate all entries itself. // files do not implement fs.ReadDirFile. It may allocate all entries itself.
entries, err := fs.ReadDir(a.native, name) entries, err := fs.ReadDir(a.native, name)
if err != nil { if err != nil {
return err return failure(err)
} }
for _, entry := range entries { for _, entry := range entries {
if !visit(entry) { if !visit(entry) {

View File

@@ -119,21 +119,34 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
} }
// A link stays active until its target has resolved, not until the entire // A link remains active until its own target resolves. Completed targets can be
// original pathname has resolved. Remaining segments may reuse a finished link. // reused while the caller continues resolving its remaining components.
type importLinkFrame struct { type importLinkFrame struct {
pending []string pending []string
link string link string
} }
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) { type importLinkOperations struct {
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}} lstat func(string) (fs.FileInfo, error)
readlink func(string) (string, error)
normalizeTarget func(string, string) (string, error)
}
func resolveImportLinkPath(name string, operations importLinkOperations) (string, error) {
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
resolved := "" resolved := ""
activeLinks := make(map[string]struct{}) active := make(map[string]struct{})
completed := make(map[string]string)
for len(frames) > 0 { for len(frames) > 0 {
frame := &frames[len(frames)-1] frame := &frames[len(frames)-1]
if len(frame.pending) == 0 { if len(frame.pending) == 0 {
delete(activeLinks, frame.link) if frame.link != "" {
if resolved == "" {
resolved = "."
}
completed[frame.link] = resolved
delete(active, frame.link)
}
frames = frames[:len(frames)-1] frames = frames[:len(frames)-1]
continue continue
} }
@@ -143,7 +156,7 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
if candidate == "" { if candidate == "" {
candidate = "." candidate = "."
} }
info, err := access.root.Lstat(filepath.FromSlash(candidate)) info, err := operations.lstat(candidate)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -151,27 +164,22 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
resolved = candidate resolved = candidate
continue continue
} }
if _, exists := activeLinks[candidate]; exists { if target, ok := completed[candidate]; ok {
resolved = target
continue
}
if _, exists := active[candidate]; exists {
return "", errImportDirectoryLinkCycle return "", errImportDirectoryLinkCycle
} }
activeLinks[candidate] = struct{}{} target, err := operations.readlink(candidate)
target, err := access.root.Readlink(filepath.FromSlash(candidate))
if err != nil { if err != nil {
return "", err return "", err
} }
var targetPath string targetPath, err := operations.normalizeTarget(candidate, target)
if filepath.IsAbs(target) { if err != nil {
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target)) return "", err
if err != nil {
return "", errImportPathOutsideRoot
}
targetPath = path.Clean(filepath.ToSlash(relative))
} else {
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
}
if !withinPortableRoot(targetPath) {
return "", errImportPathOutsideRoot
} }
active[candidate] = struct{}{}
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate}) frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
resolved = "" resolved = ""
} }
@@ -181,6 +189,37 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
return resolved, nil return resolved, nil
} }
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
return resolveImportLinkPath(filepath.ToSlash(name), hostImportLinkOperations(access))
}
func hostImportLinkOperations(access *filesystemAccess) importLinkOperations {
return importLinkOperations{
lstat: func(candidate string) (fs.FileInfo, error) {
return access.root.Lstat(filepath.FromSlash(candidate))
},
readlink: func(candidate string) (string, error) {
return access.root.Readlink(filepath.FromSlash(candidate))
},
normalizeTarget: func(candidate, target string) (string, error) {
var targetPath string
if filepath.IsAbs(target) {
relative, err := filepath.Rel(access.rootPath, filepath.Clean(target))
if err != nil {
return "", errImportPathOutsideRoot
}
targetPath = path.Clean(filepath.ToSlash(relative))
} else {
targetPath = path.Clean(path.Join(path.Dir(candidate), filepath.ToSlash(target)))
}
if !withinPortableRoot(targetPath) {
return "", errImportPathOutsideRoot
}
return targetPath, nil
},
}
}
func withinPortableRoot(name string) bool { func withinPortableRoot(name string) bool {
return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/") return name == "." || name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/")
} }
@@ -189,57 +228,27 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
if !fs.ValidPath(name) || !access.contains(name) { if !fs.ValidPath(name) || !access.contains(name) {
return "", fs.ErrInvalid return "", fs.ErrInvalid
} }
frames := []importLinkFrame{{pending: strings.Split(name, "/")}} return resolveImportLinkPath(name, importLinkOperations{
resolved := "" lstat: func(candidate string) (fs.FileInfo, error) {
activeLinks := make(map[string]struct{}) // Ancestors of a multi-component root are resolution prefixes, not
for len(frames) > 0 { // selected targets. Complete paths and link targets remain contained.
frame := &frames[len(frames)-1] if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
if len(frame.pending) == 0 { return nil, errImportPathOutsideRoot
delete(activeLinks, frame.link) }
frames = frames[:len(frames)-1] return filesystem.Lstat(candidate)
continue },
} readlink: filesystem.ReadLink,
segment := frame.pending[0] normalizeTarget: func(candidate, target string) (string, error) {
frame.pending = frame.pending[1:] if target == "" || strings.HasPrefix(target, "/") {
candidate := path.Join(resolved, segment) return "", errImportPathOutsideRoot
if candidate == "" { }
candidate = "." targetPath := path.Clean(path.Join(path.Dir(candidate), target))
} if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
// Ancestors of a multi-component root are resolution prefixes, not return "", errImportPathOutsideRoot
// selected targets. Complete paths and link targets remain contained. }
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") { return targetPath, nil
return "", errImportPathOutsideRoot },
} })
info, err := filesystem.Lstat(candidate)
if err != nil {
return "", err
}
if info.Mode()&fs.ModeSymlink == 0 {
resolved = candidate
continue
}
if _, exists := activeLinks[candidate]; exists {
return "", errImportDirectoryLinkCycle
}
activeLinks[candidate] = struct{}{}
target, err := filesystem.ReadLink(candidate)
if err != nil {
return "", err
}
if target == "" || strings.HasPrefix(target, "/") {
return "", errImportPathOutsideRoot
}
targetPath := path.Clean(path.Join(path.Dir(candidate), target))
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
return "", errImportPathOutsideRoot
}
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
resolved = ""
}
if resolved == "" {
resolved = "."
}
return resolved, nil
} }
func isImportLinkCycle(err error) bool { func isImportLinkCycle(err error) bool {

View File

@@ -2,6 +2,7 @@ package engine
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
@@ -45,3 +46,270 @@ func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
}) })
} }
} }
func TestNativeLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
// The small graph stays within host symlink-depth limits while exceeding the
// bounded linear operation allowance without completed-target reuse.
const linkCount = 5
root := t.TempDir()
configurationRoot := filepath.Join(root, "workspace", "config")
for _, directory := range []string{
filepath.Join(configurationRoot, "graph"),
filepath.Join(configurationRoot, "selected"),
} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
for index := 0; index < linkCount; index++ {
name := fmt.Sprintf("s%02d", index)
target := "."
if index > 0 {
previous := fmt.Sprintf("s%02d", index-1)
target = previous + "/" + previous
}
if err := os.Symlink(target, filepath.Join(configurationRoot, "graph", name)); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
if err := os.WriteFile(filepath.Join(configurationRoot, "graph", "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(fmt.Sprintf("../graph/s%02d", linkCount-1), filepath.Join(configurationRoot, "selected", "entry")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
base := os.DirFS(root)
if _, ok := base.(fs.ReadLinkFS); !ok {
t.Skip("host directory filesystem does not expose link inspection")
}
filesystem := newCountingReadLinkFS(base, linkCount*20+40)
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
if _, err := access.canonical("workspace/config/selected"); err != nil {
t.Fatalf("selected directory canonicalization failed before selection: %v", err)
}
var diagnostics []Diagnostic
if selector == "directory" {
_, diagnostics = selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: true\n")
} else {
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, "workspace/config", " - glob: 'selected/*/*.yml'\n follow: true\n")
}
if len(diagnostics) != 0 {
t.Fatalf("selection diagnostics = %#v after %d link operations", diagnostics, filesystem.operations)
}
if filesystem.operations == 0 || filesystem.operations > filesystem.limit {
t.Fatalf("link-resolution operations = %d, want 1..%d", filesystem.operations, filesystem.limit)
}
if filesystem.readLinks < linkCount {
t.Fatalf("link reads = %d, want the selector to resolve the repeated dependency graph", filesystem.readLinks)
}
})
}
}
func TestNativeLinkResolutionRejectsEscapesAndSkipsWhenFollowingIsDisabled(t *testing.T) {
root := t.TempDir()
configurationRoot := filepath.Join(root, "workspace", "config")
selected := filepath.Join(configurationRoot, "selected")
if err := os.MkdirAll(selected, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../../../outside", filepath.Join(selected, "escape")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
base := os.DirFS(root)
if _, ok := base.(fs.ReadLinkFS); !ok {
t.Skip("host directory filesystem does not expose link inspection")
}
filesystem := newCountingReadLinkFS(base, 100)
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
if _, err := resolveNativeImportPath(access, filesystem.links, "workspace/config/selected/escape"); !errors.Is(err, errImportPathOutsideRoot) {
t.Fatalf("escaping native link error = %v, want outside-root rejection", err)
}
filesystem.readLinks = 0
_, diagnostics := selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: false\n")
if len(diagnostics) != 0 || filesystem.readLinks != 0 {
t.Fatalf("follow-disabled selection diagnostics=%#v readlink calls=%d", diagnostics, filesystem.readLinks)
}
}
func TestHostLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
// The small graph stays within host symlink-depth limits while exceeding the
// bounded linear operation allowance without completed-target reuse.
const linkCount = 5
root := t.TempDir()
configurationRoot := filepath.Join(root, "workspace", "config")
graph := filepath.Join(configurationRoot, "graph")
selected := filepath.Join(configurationRoot, "selected")
for _, directory := range []string{graph, selected} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
for index := 0; index < linkCount; index++ {
name := fmt.Sprintf("s%02d", index)
target := "."
if index > 0 {
previous := fmt.Sprintf("s%02d", index-1)
target = previous + "/" + previous
}
if err := os.Symlink(target, filepath.Join(graph, name)); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
if err := os.WriteFile(filepath.Join(graph, "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
t.Fatal(err)
}
aliasTarget := fmt.Sprintf("../graph/s%02d", linkCount-1)
if err := os.Symlink(aliasTarget, filepath.Join(selected, "entry")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
containedAbsolute := filepath.Join(graph, "s00")
if err := os.Symlink(containedAbsolute, filepath.Join(selected, "absolute")); err != nil {
t.Skipf("absolute symlinks unavailable: %v", err)
}
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(selected, "escape")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
access, err := openHostAccess(root)
if err != nil {
t.Fatal(err)
}
defer access.close()
name := filepath.ToSlash(filepath.Join("workspace", "config", "selected", "entry"))
operations := hostImportLinkOperations(access)
var operationCount int
lstat := operations.lstat
readlink := operations.readlink
operations.lstat = func(candidate string) (fs.FileInfo, error) {
operationCount++
if operationCount > linkCount*20+40 {
return nil, errors.New("host link operation limit exceeded")
}
return lstat(candidate)
}
operations.readlink = func(candidate string) (string, error) {
operationCount++
if operationCount > linkCount*20+40 {
return "", errors.New("host link operation limit exceeded")
}
return readlink(candidate)
}
resolved, err := resolveImportLinkPath(name, operations)
if err != nil || resolved != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
t.Fatalf("host repeated-link resolution = %q, %v", resolved, err)
}
if operationCount == 0 || operationCount > linkCount*20+40 {
t.Fatalf("host link operations = %d, want 1..%d", operationCount, linkCount*20+40)
}
contained, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "absolute"))
if err != nil || contained != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
t.Fatalf("contained absolute link = %q, %v", contained, err)
}
if _, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "escape")); !errors.Is(err, errImportPathOutsideRoot) {
t.Fatalf("escaping absolute link error = %v, want outside-root rejection", err)
}
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
var diagnostics []Diagnostic
if selector == "directory" {
_, diagnostics = selectDirectoryWithDiagnostics(t, access, root, " - dir: workspace/config/selected/entry\n follow: true\n")
} else {
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, root, " - glob: 'workspace/config/selected/entry/*.yml'\n follow: true\n")
}
if len(diagnostics) != 0 {
t.Fatalf("host selector diagnostics = %#v", diagnostics)
}
})
}
}
func TestHostLinkResolutionKeepsNamespacesIndependent(t *testing.T) {
for _, test := range []struct {
name string
target string
}{
{name: "left", target: "left-target"},
{name: "right", target: "right-target"},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, test.target), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(test.target, filepath.Join(root, "alias")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
access, err := openHostAccess(root)
if err != nil {
t.Fatal(err)
}
defer access.close()
if got, err := resolveHostImportPath(access, "alias"); err != nil || got != test.target {
t.Fatalf("namespace link = %q, %v; want %q", got, err, test.target)
}
})
}
}
type countingReadLinkFS struct {
filesystem fs.FS
links fs.ReadLinkFS
operations int
readLinks int
limit int
}
func newCountingReadLinkFS(filesystem fs.FS, limit int) *countingReadLinkFS {
links, ok := filesystem.(fs.ReadLinkFS)
if !ok {
panic("test filesystem does not support link inspection")
}
return &countingReadLinkFS{filesystem: filesystem, links: links, limit: limit}
}
func (f *countingReadLinkFS) Open(name string) (fs.File, error) { return f.filesystem.Open(name) }
func (f *countingReadLinkFS) Stat(name string) (fs.FileInfo, error) {
if statter, ok := f.filesystem.(fs.StatFS); ok {
return statter.Stat(name)
}
return fs.Stat(f.filesystem, name)
}
func (f *countingReadLinkFS) ReadDir(name string) ([]fs.DirEntry, error) {
if reader, ok := f.filesystem.(fs.ReadDirFS); ok {
return reader.ReadDir(name)
}
return fs.ReadDir(f.filesystem, name)
}
func (f *countingReadLinkFS) count() error {
f.operations++
if f.operations > f.limit {
return errors.New("native link operation limit exceeded")
}
return nil
}
func (f *countingReadLinkFS) Lstat(name string) (fs.FileInfo, error) {
if err := f.count(); err != nil {
return nil, err
}
return f.links.Lstat(name)
}
func (f *countingReadLinkFS) ReadLink(name string) (string, error) {
if err := f.count(); err != nil {
return "", err
}
f.readLinks++
return f.links.ReadLink(name)
}

View File

@@ -56,45 +56,13 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
continue continue
} }
for _, entry := range entries { for _, entry := range entries {
matched, err := path.Match(segment, entry.Name()) candidate, selected, diagnostic := prepareGlobEntry(access, basePath, parent, entry, segment, last, policy)
if err != nil { if diagnostic != nil {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")} return importSelection{}, []Diagnostic{*diagnostic}
} }
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") { if selected {
continue nextByPath[candidate.path] = candidate
} }
accessPath := access.join(parent.accessPath, entry.Name())
lexicalPath := access.join(parent.path, entry.Name())
if !access.contains(accessPath) || !access.contains(lexicalPath) {
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
}
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
if err != nil {
return importSelection{}, []Diagnostic{globCandidateDiagnostic(err, site.location, "glob match could not be inspected")}
}
if target.symlink && !policy.follow {
continue
}
if !last && !target.dir {
continue
}
physicalPath := target.identity
// Native source identity and relative imports use the namespace
// spelling; resolved link identities belong only to discovery.
if access.native != nil {
physicalPath = accessPath
}
if physicalPath == "" {
physicalPath, err = access.canonical(accessPath)
if err != nil {
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", site.location)}
}
}
candidate := selectedImportCandidate{
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: site,
}
nextByPath[candidate.path] = candidate
} }
} }
candidates = candidatesFromMap(nextByPath) candidates = candidatesFromMap(nextByPath)
@@ -120,12 +88,53 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
// Source allowance is charged by source admission, after this filtered set // Source allowance is charged by source admission, after this filtered set
// reaches the traversal; that preserves repeated-source diagnostics first. // reaches the traversal; that preserves repeated-source diagnostics first.
selection := importSelection{candidates: selected} selection := importSelection{candidates: selected}
if len(selected) != 0 {
selection.outcome = importSelectionHasCandidates
}
return selection, nil return selection, nil
} }
func prepareGlobEntry(access *filesystemAccess, basePath string, parent selectedImportCandidate, entry fs.DirEntry, pattern string, last bool, policy resolvedImportPolicy) (selectedImportCandidate, bool, *Diagnostic) {
name := entry.Name()
matched, err := path.Match(pattern, name)
if err != nil {
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportInvalid, "import glob pattern is invalid")
}
if !matched || !policy.hidden && strings.HasPrefix(name, ".") {
return selectedImportCandidate{}, false, nil
}
accessPath := access.join(parent.accessPath, name)
lexicalPath := access.join(parent.path, name)
if !access.contains(accessPath) || !access.contains(lexicalPath) {
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")
}
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
if err != nil {
diagnostic := globCandidateDiagnostic(err, parent.site.location, "glob match could not be inspected")
return selectedImportCandidate{}, false, &diagnostic
}
if target.symlink && !policy.follow || !last && !target.dir {
return selectedImportCandidate{}, false, nil
}
physicalPath := target.identity
// Native source identity and relative imports use the namespace spelling;
// resolved link identities belong only to discovery.
if access.native != nil {
physicalPath = accessPath
}
if physicalPath == "" {
physicalPath, err = access.canonical(accessPath)
if err != nil {
diagnostic := globFilesystemDiagnostic(err, "glob match could not be canonicalized", parent.site.location)
return selectedImportCandidate{}, false, &diagnostic
}
}
return selectedImportCandidate{
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
label: relativeImportLabel(access, basePath, lexicalPath), site: parent.site,
}, true, nil
}
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) { func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
if !access.contains(candidatePath) || !access.contains(accessPath) { if !access.contains(candidatePath) || !access.contains(accessPath) {
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")} return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
@@ -191,7 +200,7 @@ func readGlobEntries(access *filesystemAccess, directory string, location Locati
if errors.Is(err, errImportDirectoryEntryLimit) { if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)} return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
} }
if errors.Is(err, fs.ErrNotExist) { if missingDirectoryOpen(err) {
return nil, true, nil return nil, true, nil
} }
if err != nil { if err != nil {

View File

@@ -99,7 +99,7 @@ func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -181,7 +181,7 @@ func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, ba
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics) t.Fatalf("parse diagnostics = %#v", diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("policy diagnostics = %#v", diagnostics) t.Fatalf("policy diagnostics = %#v", diagnostics)
} }

View File

@@ -40,6 +40,86 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
return true return true
} }
func (c *retainedNodeCounter) canAddClones(roots []*document.Node, copies int) bool {
if c == nil || c.limit < 0 {
return true
}
remaining := c.limit - c.count
if remaining < 0 {
return false
}
for copyIndex := 0; copyIndex < copies; copyIndex++ {
for _, root := range roots {
count, within := documentCloneNodeCount(root, remaining)
if !within {
return false
}
remaining -= count
}
}
return true
}
func documentCloneNodeCount(root *document.Node, limit int) (int, bool) {
if root == nil {
return 0, true
}
count := 0
stack := []*document.Node{root}
for len(stack) != 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if node == nil {
continue
}
count++
if count > limit {
return 0, false
}
for _, entry := range node.Mapping {
stack = append(stack, entry.Value)
}
stack = append(stack, node.Sequence...)
}
return count, true
}
func addInspectionHistory(counter *retainedNodeCounter, inspection *Inspection) bool {
if inspection == nil {
return false
}
for _, root := range []*document.Node{inspection.baseline, inspection.input, inspection.final, inspection.preCLI} {
if !counter.add(root) {
return false
}
}
for _, application := range inspection.applications {
if !counter.add(application.value) {
return false
}
}
for _, application := range inspection.postDecodeApps {
if !counter.add(application.value) {
return false
}
}
for _, snapshots := range [][]resolutionSnapshot{inspection.resolution, inspection.resolutionReplay} {
for _, snapshot := range snapshots {
if !counter.add(snapshot.before) || !counter.add(snapshot.after) {
return false
}
}
}
for _, operations := range [][]inheritanceOperation{inspection.inheritance, inspection.metadata.inheritance} {
for _, operation := range operations {
if !counter.add(operation.parent) || !counter.add(operation.effective) {
return false
}
}
}
return true
}
// History applications borrow immutable policy-validated input trees. Metadata // History applications borrow immutable policy-validated input trees. Metadata
// records answer presence and origin queries; replay reconstructs value changes. // records answer presence and origin queries; replay reconstructs value changes.
// Replay reconciles with canonical decoding, applies supplied CLI contributions, // Replay reconciles with canonical decoding, applies supplied CLI contributions,
@@ -237,10 +317,6 @@ func applyHistoryApplication(root **document.Node, application historyApplicatio
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath) mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
} }
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
}
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) { func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
if incoming == nil { if incoming == nil {
return return
@@ -337,6 +413,27 @@ func applySequenceHistory(target **document.Node, incoming *document.Node, contr
} }
} }
func sequenceHistoryContributionForCombination(
destination Path,
operation sequenceMode,
incoming *document.Node,
combined sequenceCombinationResult,
) sequenceHistoryContribution {
contribution := sequenceHistoryContribution{
path: destination.clone(), mode: sequenceHistoryMapped,
}
if operation == sequenceModeReplace {
contribution.mode = sequenceHistoryReplace
if incoming != nil && incoming.Kind == document.Sequence {
contribution.length = len(incoming.Sequence)
}
return contribution
}
contribution.items = append([]sequenceItemProjection(nil), combined.higherItems...)
contribution.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
return contribution
}
func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution { func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution {
if values == nil { if values == nil {
return nil return nil
@@ -503,66 +600,3 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
} }
} }
} }
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...)
}
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
seen := make(map[*document.Node]bool)
count := 0
var visit func(*document.Node) bool
visit = func(node *document.Node) bool {
if node == nil || seen[node] {
return true
}
seen[node] = true
count++
if count > limit {
return false
}
for _, entry := range node.Mapping {
if !visit(entry.Value) {
return false
}
}
for _, child := range node.Sequence {
if !visit(child) {
return false
}
}
return true
}
if !visit(baseline) || !visit(input) || !visit(final) {
return false
}
for _, application := range applications {
if !visit(application.value) {
return false
}
}
for _, resolution := range resolutions {
if !visit(resolution.before) || !visit(resolution.after) {
return false
}
}
for _, expansion := range inherited {
if expansion == nil {
continue
}
if !visit(expansion.root) {
return false
}
for _, operation := range expansion.operations {
if !visit(operation.parent) || !visit(operation.effective) {
return false
}
}
for _, replacement := range expansion.replacements {
if !visit(replacement.before) || !visit(replacement.after) {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,210 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type historyBudgetSimpleConfig struct {
Value string
}
type historyBudgetRecord struct {
Value string
Credential Secret
}
type historyBudgetLayeredConfig struct {
Parent historyBudgetRecord
Child historyBudgetRecord
}
func retainedInspectionNodeCountForTest(inspection *Inspection) int {
seen := make(map[*document.Node]struct{})
stack := []*document.Node{inspection.baseline, inspection.input, inspection.final, inspection.preCLI}
for _, application := range inspection.applications {
stack = append(stack, application.value)
}
for _, application := range inspection.postDecodeApps {
stack = append(stack, application.value)
}
for _, snapshot := range inspection.resolution {
stack = append(stack, snapshot.before, snapshot.after)
}
for _, snapshot := range inspection.resolutionReplay {
stack = append(stack, snapshot.before, snapshot.after)
}
for _, operation := range inspection.inheritance {
stack = append(stack, operation.parent, operation.effective)
}
for _, operation := range inspection.metadata.inheritance {
stack = append(stack, operation.parent, operation.effective)
}
for len(stack) != 0 {
last := len(stack) - 1
node := stack[last]
stack = stack[:last]
if node == nil {
continue
}
if _, exists := seen[node]; exists {
continue
}
seen[node] = struct{}{}
for _, entry := range node.Mapping {
stack = append(stack, entry.Value)
}
stack = append(stack, node.Sequence...)
}
return len(seen)
}
func TestLoadHistoryBudgetUsesExactRetainedNodeCount(t *testing.T) {
load := func(historyLimit int, validations *int) (*Loaded[historyBudgetSimpleConfig], error) {
return loadWithSecretLookupLimit(
Bytes("main", []byte("value: retained\n")),
testSecretBinding(),
func(string) (string, bool) { return "", false },
historyLimit,
Options[historyBudgetSimpleConfig]{Definition: Definition[historyBudgetSimpleConfig]{
Validate: func(*historyBudgetSimpleConfig, *Validation) { *validations++ },
}},
)
}
var baselineValidations int
baseline, err := load(10000, &baselineValidations)
if err != nil {
t.Fatalf("baseline load: %v", err)
}
want := retainedInspectionNodeCountForTest(baseline.Inspection)
if want == 0 {
t.Fatal("independent retained-tree count was empty")
}
var exactValidations int
loaded, err := load(want, &exactValidations)
if err != nil || loaded == nil {
t.Fatalf("load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
}
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
t.Fatalf("retained node count = %d, want %d", got, want)
}
if exactValidations != 1 {
t.Fatalf("validation calls at exact count = %d, want 1", exactValidations)
}
var rejectedValidations int
loaded, err = load(want-1, &rejectedValidations)
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
t.Fatalf("load below retained-node count: result=%v error=%v", loaded, err)
}
if rejectedValidations != 0 {
t.Fatalf("validation ran before history rejection: %d calls", rejectedValidations)
}
}
func TestLayeredHistoryBudgetRejectsBeforeSecretsAndValidation(t *testing.T) {
type callbackCounts struct {
lookups int
validations int
}
makeRequest := func(counts *callbackCounts) loadRequest[historyBudgetLayeredConfig] {
return loadRequest[historyBudgetLayeredConfig]{
main: loadInput{source: Bytes("main", []byte("child:\n extends: parent\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("underlay", []byte(
"parent:\n value: inherited\n credential: HISTORY_BUDGET_SECRET\n",
))}}}},
overrides: []Override{{Path: Keys("child", "value"), Value: "cli", Flag: "--child-value"}},
definition: Definition[historyBudgetLayeredConfig]{
Resolve: func(configuration *historyBudgetLayeredConfig, resolution *Resolution) error {
configuration.Child.Value += "-resolved"
resolution.Record(Keys("child", "value"), ChangeNormalization, "normalize child")
return nil
},
Validate: func(*historyBudgetLayeredConfig, *Validation) { counts.validations++ },
},
}
}
lookup := func(counts *callbackCounts) secretLookup {
return func(name string) (string, bool) {
counts.lookups++
if name != "HISTORY_BUDGET_SECRET" {
t.Errorf("secret lookup name = %q", name)
}
return "resolved-secret", true
}
}
baselineCounts := &callbackCounts{}
baseline, err := loadGroupedWithSecretLookupLimit(makeRequest(baselineCounts), testSecretBinding(), lookup(baselineCounts), 10000)
if err != nil {
t.Fatalf("baseline grouped load: %v", err)
}
want := retainedInspectionNodeCountForTest(baseline.Inspection)
if want == 0 {
t.Fatal("independent retained-tree count was empty")
}
if len(baseline.Inspection.inheritance) == 0 || len(baseline.Inspection.metadata.inheritance) == 0 {
t.Fatal("layered load did not retain both inheritance evidence copies")
}
exactCounts := &callbackCounts{}
loaded, err := loadGroupedWithSecretLookupLimit(makeRequest(exactCounts), testSecretBinding(), lookup(exactCounts), want)
if err != nil || loaded == nil {
t.Fatalf("grouped load at exact retained-node count %d: result=%v error=%v", want, loaded, err)
}
if got := retainedInspectionNodeCountForTest(loaded.Inspection); got != want {
t.Fatalf("grouped retained node count = %d, want %d", got, want)
}
if loaded.Config.Child.Value != "cli-resolved" || exactCounts.lookups != 1 || exactCounts.validations != 1 {
t.Fatalf("grouped lifecycle result=%#v callbacks=%#v", loaded.Config, exactCounts)
}
rejectedCounts := &callbackCounts{}
loaded, err = loadGroupedWithSecretLookupLimit(makeRequest(rejectedCounts), testSecretBinding(), lookup(rejectedCounts), want-1)
if loaded != nil || !hasHistoryLimitDiagnostic(t, err) {
t.Fatalf("grouped load below retained-node count: result=%v error=%v", loaded, err)
}
if rejectedCounts.lookups != 0 || rejectedCounts.validations != 0 {
t.Fatalf("callbacks ran before history rejection: %#v", rejectedCounts)
}
}
func TestPreparedInheritanceEvidenceOwnsDistinctTrees(t *testing.T) {
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "shared"}}}}
resolved := resolvedLoad[historyBudgetSimpleConfig]{
inheritance: &inheritanceScope{operations: []inheritanceOperation{{parent: root, effective: root}}},
}
counter := newRetainedNodeCounter(8)
inspection, ok := prepareInspectionHistory(resolved, counter)
if !ok {
t.Fatal("preparation rejected the exact size of the distinct retained copies")
}
if len(inspection.inheritance) != 1 || len(inspection.metadata.inheritance) != 1 {
t.Fatalf("inheritance copies = inspection %#v, metadata %#v", inspection.inheritance, inspection.metadata.inheritance)
}
inspectionOperation := inspection.inheritance[0]
metadataOperation := inspection.metadata.inheritance[0]
if inspectionOperation.parent == metadataOperation.parent || inspectionOperation.effective == metadataOperation.effective || counter.count != 8 {
t.Fatalf("separate inheritance copies were not counted independently: inspection=(%p, %p) metadata=(%p, %p) count=%d", inspectionOperation.parent, inspectionOperation.effective, metadataOperation.parent, metadataOperation.effective, counter.count)
}
}
func hasHistoryLimitDiagnostic(t *testing.T, err error) bool {
t.Helper()
if err == nil {
return false
}
loadErr, ok := err.(*LoadError)
if !ok {
t.Fatalf("error type = %T, want *LoadError", err)
}
for _, diagnostic := range loadErr.Diagnostics() {
if diagnostic.Code == CodeExplainLimitExceeded {
return true
}
}
return false
}

View File

@@ -11,17 +11,18 @@ import (
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) { func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{ policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}, {selector: sequenceIdentitySelector{
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}}, literalSequenceSelector("collections"), anyMappingSequenceSelector(), literalSequenceSelector("records"),
}, identityFields: []string{"id"}},
}) })
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n") first := parseCompositionDocument(t, "source-1", "collections: {north: {records: [{id: a, value: first}]}, south: {records: [{id: x}]}}\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n") second := parseCompositionDocument(t, "source-2", "collections: {north: {records: [{id: b, value: second}]}, south: {records: [{id: y}]}}\n")
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second) composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("compose diagnostics = %#v", diagnostics) t.Fatalf("compose diagnostics = %#v", diagnostics)
} }
for applicationIndex, application := range composed.applications { for applicationIndex, application := range composed.applications {
for path, want := range map[string]int{"/records": 1, "/other": 1} { for path, want := range map[string]int{"/collections/north/records": 1, "/collections/south/records": 1} {
sequence, present := documentAtPath(application.value, mustParsePath(t, path)) sequence, present := documentAtPath(application.value, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want { if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != want {
t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present) t.Errorf("application %d retained %s = %#v, %v; want one source fragment item", applicationIndex, path, sequence, present)
@@ -29,14 +30,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
} }
} }
working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nother: [{id: default}]\n") working := parseCompositionDocument(t, "defaults", "collections: {north: {records: [{id: default}]}, south: {records: [{id: default}]}}\n")
index := new(mappingIndex) index := new(mappingIndex)
for _, application := range composed.applications { for _, application := range composed.applications {
applyHistoryApplication(&working, application, index) applyHistoryApplication(&working, application, index)
} }
for path, want := range map[string][]string{ for path, want := range map[string][]string{
"/records": {"a", "b"}, "/collections/north/records": {"a", "b"},
"/other": {"x", "y"}, "/collections/south/records": {"x", "y"},
} { } {
sequence, present := documentAtPath(working, mustParsePath(t, path)) sequence, present := documentAtPath(working, mustParsePath(t, path))
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) { if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
@@ -53,14 +54,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
application := composed.applications[1] application := composed.applications[1]
cloned := cloneHistoryApplications(composed.applications) cloned := cloneHistoryApplications(composed.applications)
cloned[1].sequences[0].path.segments[0].key = "mutated" cloned[1].sequences[0].path.segments[0].key = "mutated"
if composed.applications[1].sequences[0].path.String() != "/records" { if composed.applications[1].sequences[0].path.String() != "/collections/north/records" {
t.Fatal("cloned sequence history path aliases its source") t.Fatal("cloned sequence history path aliases its source")
} }
addedPath := Keys("records").Index(1).Key("value") addedPath := Keys("collections", "north", "records").Index(1).Key("value")
if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() { if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() {
t.Fatalf("appended value operation = %s, %v", operation.String(), relevant) t.Fatalf("appended value operation = %s, %v", operation.String(), relevant)
} }
if _, relevant := applicationOperationPath(application, Keys("records").Index(0).Key("value"), nil); relevant { if _, relevant := applicationOperationPath(application, Keys("collections", "north", "records").Index(0).Key("value"), nil); relevant {
t.Fatal("later contribution was reported as changing an earlier record") t.Fatal("later contribution was reported as changing an earlier record")
} }
origin := historyApplicationOrigin(application, addedPath, nil) origin := historyApplicationOrigin(application, addedPath, nil)
@@ -69,10 +70,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
} }
overlay := historyApplication{ overlay := historyApplication{
layer: LayerOverlay, layer: LayerOverlay,
value: parseCompositionDocument(t, "overlay", "records: []\n"), value: parseCompositionDocument(t, "overlay", "collections: {north: {records: []}}\n"),
} }
applyHistoryApplication(&working, overlay, index) applyHistoryApplication(&working, overlay, index)
replaced, present := documentAtPath(working, Keys("records")) replaced, present := documentAtPath(working, Keys("collections", "north", "records"))
if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 { if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 {
t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present) t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present)
} }
@@ -469,21 +470,18 @@ func TestSequenceHistoryReplaysCombinationEvidence(t *testing.T) {
t.Fatalf("combine diagnostics = %#v", diagnostics) t.Fatalf("combine diagnostics = %#v", diagnostics)
} }
mode := sequenceHistoryMapped
if test.operation == sequenceModeReplace {
mode = sequenceHistoryReplace
}
length := 0
if mode == sequenceHistoryReplace {
length = len(combined.higherItems)
}
application := historyApplication{ application := historyApplication{
layer: LayerOverlay, layer: LayerOverlay,
value: higherRoot, value: higherRoot,
sequences: []sequenceHistoryContribution{{ sequences: []sequenceHistoryContribution{
path: Keys("records"), mode: mode, length: length, sequenceHistoryContributionForCombination(Keys("records"), test.operation, higher, combined),
items: combined.higherItems, replacements: combined.replacements, },
}}, }
if len(combined.higherItems) != 0 {
combined.higherItems[0].outputIndex++
}
if len(combined.replacements) != 0 {
combined.replacements[0].outputIndex++
} }
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences) application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
replayed := document.Clone(lowerRoot) replayed := document.Clone(lowerRoot)

View File

@@ -61,11 +61,20 @@ func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionP
} }
func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) { func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) {
closed := false
closeAccess := func() error {
if closed {
return nil
}
closed = true
return access.close()
}
defer func() { _ = closeAccess() }()
main := request.main main := request.main
destination := request.input.input.destination destination := request.input.input.destination
limits := policy.importLimits limits := policy.importLimits
accounting := loadState.accounting accounting := loadState.accounting
versionState := loadState.versionState
processing := request.processing processing := request.processing
basePath, mainPath := resolved.basePath, resolved.mainPath basePath, mainPath := resolved.basePath, resolved.mainPath
if tier.visited == nil { if tier.visited == nil {
@@ -73,19 +82,19 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
} }
visited := tier.visited visited := tier.visited
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) { if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
_ = access.close() _ = closeAccess()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)} return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
} }
for _, directory := range []string{access.rootPath, basePath} { for _, directory := range []string{access.rootPath, basePath} {
info, err := access.stat(directory) info, err := access.stat(directory)
if err != nil || !info.IsDir() { if err != nil || !info.IsDir() {
_ = access.close() _ = closeAccess()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)} return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)}
} }
} }
mainInfo := cloneSourceInfo(*main.source) mainInfo := cloneSourceInfo(*main.source)
mainInfo.Destination = destination.clone() mainInfo.Destination = destination.clone()
mainIsItem := main.source.placementMode == placementModeSequenceItem mainIsItem := main.source.placementMode == PlacementModeSequenceItem
if mainPath != "" { if mainPath != "" {
mainInfo.Path = mainPath mainInfo.Path = mainPath
} }
@@ -95,7 +104,7 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
mainResource = access.syntheticIdentity() mainResource = access.syntheticIdentity()
} }
if previous, repeated := visited[mainResource]; repeated { if previous, repeated := visited[mainResource]; repeated {
_ = access.close() _ = closeAccess()
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})} return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
} }
accounting.sources++ accounting.sources++
@@ -125,7 +134,7 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
} }
if diagnostic := state.checkInitialLimits(); diagnostic != nil { if diagnostic := state.checkInitialLimits(); diagnostic != nil {
_ = access.close() _ = closeAccess()
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic} return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
} }
@@ -181,7 +190,7 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
scheduleAcquisitionScopes(&state.scope, indexImportDependencies(state.importGraph)) scheduleAcquisitionScopes(&state.scope, indexImportDependencies(state.importGraph))
} }
closeErr := access.close() closeErr := closeAccess()
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
if mainIsItem { if mainIsItem {
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0)) diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
@@ -203,25 +212,13 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
}}, }},
} }
} }
if request.input.deferAssembly { if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil { return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
return &composedDocument{
acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
}, cloneSourceInfos(state.sources), nil
} }
acquired := &composedDocument{ return &composedDocument{
acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)}, acquisition: []acquisitionScope{cloneAcquisitionScope(acquisition)},
importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes, importGraph: state.importGraph.clone(), inputBytes: accounting.bytes, inputNodes: accounting.nodes,
} }, cloneSourceInfos(state.sources), nil
input := assembledInput{
parsed: main, composition: acquired, acquisition: cloneAcquisitionScopes(acquired.acquisition),
sources: state.sources, versionState: versionState, accounting: accounting,
}
composed, diagnostics := composeAcquiredInputWithPolicies(input, collectionPolicyScope{}, policy.sequencePolicy)
return composed, cloneSourceInfos(state.sources), diagnostics
} }
func (s *importTraversal) checkInitialLimits() *Diagnostic { func (s *importTraversal) checkInitialLimits() *Diagnostic {
@@ -247,7 +244,7 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return diagnostics return diagnostics
} }
if policy.placement == placementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 { if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
site := spec.fieldSites["placement"] site := spec.fieldSites["placement"]
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}} return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
} }
@@ -308,8 +305,8 @@ func (s *importTraversal) admitImportSource(canonical string, identity resourceI
label = s.access.label + "/" + canonical label = s.access.label + "/" + canonical
} }
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location} info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
if policy.placement == placementModeSequenceItem { if policy.placement == PlacementModeSequenceItem {
info.placementMode = placementModeSequenceItem info.placementMode = PlacementModeSequenceItem
} }
// A source's own item placement still has an exact sequence-container // A source's own item placement still has an exact sequence-container
// destination. Only an unresolved enclosing item index makes the path a // destination. Only an unresolved enclosing item index makes the path a
@@ -436,17 +433,17 @@ func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec impo
admitted := admission.source admitted := admission.source
placementDestination := destination placementDestination := destination
mode := placementModeMapping mode := PlacementModeMapping
if policy.placement == placementModeSequenceItem { if policy.placement == PlacementModeSequenceItem {
mode = placementModeSequenceItem mode = PlacementModeSequenceItem
} }
role := importRoleMapping role := importRoleMapping
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
role = importRoleItem role = importRoleItem
} }
s.importGraph.addNode(importGraphNode{key: admitted.nodeKey, canonical: admitted.canonical, sourceID: admitted.source.ID, role: role}) s.importGraph.addNode(importGraphNode{key: admitted.nodeKey, canonical: admitted.canonical, sourceID: admitted.source.ID, role: role})
s.importGraph.addEdge(importerKey, admitted.nodeKey, location) s.importGraph.addEdge(importerKey, admitted.nodeKey, location)
if mode == placementModeSequenceItem { if mode == PlacementModeSequenceItem {
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey} route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
s.active[admitted.identity] = len(s.stack) s.active[admitted.identity] = len(s.stack)
s.tier.visited[admitted.identity] = route s.tier.visited[admitted.identity] = route

View File

@@ -213,7 +213,7 @@ func projectAcquiredDestinations(composed *composedDocument, scope acquisitionSc
itemIndex = projected itemIndex = projected
} }
} }
if contribution.source.placementMode == placementModeSequenceItem { if contribution.source.placementMode == PlacementModeSequenceItem {
container := appendDestination(contributionPrefix, contribution.destination) container := appendDestination(contributionPrefix, contribution.destination)
contribution.source.Destination = container.clone() contribution.source.Destination = container.clone()
contribution.source.destinationUnresolved = false contribution.source.destinationUnresolved = false

View File

@@ -0,0 +1,108 @@
package engine
import (
"errors"
"os"
"path/filepath"
"testing"
"testing/fstest"
)
func TestImportedAcquisitionClosesOwnedHostRootOnEveryExit(t *testing.T) {
tests := []struct {
name string
main string
child string
version bool
wantError bool
wantPanic bool
}{
{name: "success", main: "configkit: {imports: [{path: child.yml}]}\n", child: "value: child\n"},
{name: "error", main: "configkit: {imports: [{path: missing.yml, required: true}]}\n", wantError: true},
{name: "version panic", main: "configkit: {imports: [{path: child.yml}]}\n", child: "version: v1\n", version: true, wantPanic: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rootPath := t.TempDir()
mainPath := filepath.Join(rootPath, "main.yml")
if err := os.WriteFile(mainPath, []byte(test.main), 0o600); err != nil {
t.Fatal(err)
}
if test.child != "" {
if err := os.WriteFile(filepath.Join(rootPath, "child.yml"), []byte(test.child), 0o600); err != nil {
t.Fatal(err)
}
}
source := File(mainPath)
main, diagnostics := parseSource(source, "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("main parse diagnostics = %#v", diagnostics)
}
access, err := openHostAccess(rootPath)
if err != nil {
t.Fatal(err)
}
ownedRoot := access.root
sentinel := errors.New("imported version checker panic sentinel")
var rule *VersionRule
var versionState *sourceVersionState
if test.version {
rule = &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { panic(sentinel) }}
versionState = &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
}
request := testImportRequest(source, nil, main, Path{}, rule, nil)
policy := &acquisitionPolicy{importLimits: defaultImportLimits()}
state := &acquisitionState{accounting: &importAccounting{}, versionState: versionState}
tier := &tierAcquisitionState{}
resolved := resolvedFilesystemContext{rootPath: rootPath, basePath: rootPath, mainPath: mainPath}
var resultDiagnostics []Diagnostic
var recovered any
func() {
defer func() { recovered = recover() }()
_, _, resultDiagnostics = acquireImportedWithAccess(request, policy, state, tier, access, resolved)
}()
if test.wantPanic {
if recovered != sentinel {
t.Fatalf("recovered panic = %#v, want original sentinel", recovered)
}
} else if recovered != nil {
t.Fatalf("unexpected panic = %#v", recovered)
}
if test.wantError != (len(resultDiagnostics) != 0) {
t.Fatalf("diagnostics = %#v, want error=%v", resultDiagnostics, test.wantError)
}
if _, err := ownedRoot.Stat("."); err == nil {
t.Fatal("owned host root remained usable after acquisition returned")
}
})
}
}
func TestImportedAcquisitionClosesChildFileBeforeVersionPanic(t *testing.T) {
filesystem := &observedFilesystem{files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}]}\n")},
"child.yml": &fstest.MapFile{Data: []byte("version: v1\n")},
}}
namespace := newNativeNamespace("bundle", filesystem)
source := filesystemFile(namespace, "main.yml")
main, diagnostics := parseSource(source, "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("main parse diagnostics = %#v", diagnostics)
}
sentinel := errors.New("imported version checker panic sentinel")
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { panic(sentinel) }}
versionState := &sourceVersionState{localRule: rule, effectiveRule: rule, mode: sourceVersionModeRequired}
request := testImportRequest(source, nil, main, Path{}, rule, nil)
access := newNativeAccess(filesystem, ".", namespace.token)
var recovered any
func() {
defer func() { recovered = recover() }()
_, _, _ = acquireImportedWithAccess(request, &acquisitionPolicy{importLimits: defaultImportLimits()},
&acquisitionState{accounting: &importAccounting{}, versionState: versionState}, &tierAcquisitionState{}, access,
resolvedFilesystemContext{native: namespace, rootPath: ".", basePath: ".", mainPath: source.path})
}()
if recovered != sentinel {
t.Fatalf("recovered panic = %#v, want original sentinel", recovered)
}
filesystem.assertClosed(t)
}

View File

@@ -73,12 +73,12 @@ func composeAcquiredInputWithPolicies(input assembledInput, lower collectionPoli
} }
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)} completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
projectNestedEvidence(completed, localScope) projectNestedEvidence(completed, localScope)
_, placed, err := placeDocumentWithMode(completed.root, main.destination, placementModeSequenceItem, main.payload.Location, maxDocumentDepth, maxDocumentNodes) _, placed, err := placeDocumentWithMode(completed.root, main.destination, PlacementModeSequenceItem, main.payload.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil { if err != nil {
return nil, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")} return nil, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} }
if !main.placementCharged { if !main.placementCharged {
placementNodes := destinationWrapperNodeCountForMode(main.destination, placementModeSequenceItem) placementNodes := destinationWrapperNodeCountForMode(main.destination, PlacementModeSequenceItem)
if accounting.nodes+placementNodes > traversal.policy.importLimits.nodes { if accounting.nodes+placementNodes > traversal.policy.importLimits.nodes {
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", main.source.ImportedFrom)} return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", main.source.ImportedFrom)}
} }
@@ -154,7 +154,7 @@ func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits,
if contribution.pendingItem == nil { if contribution.pendingItem == nil {
continue continue
} }
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem) placementNodes := destinationWrapperNodeCountForMode(contribution.destination, PlacementModeSequenceItem)
if accounting.nodes+placementNodes > limits.nodes { if accounting.nodes+placementNodes > limits.nodes {
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom) diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)
return &diagnostic return &diagnostic
@@ -228,11 +228,11 @@ func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnos
// item has its effective coordinates, not against its local field names. // item has its effective coordinates, not against its local field names.
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)} completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
projectNestedEvidence(completed, *itemScope) projectNestedEvidence(completed, *itemScope)
_, placed, err := placeDocumentWithMode(completed.root, contribution.destination, placementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes) _, placed, err := placeDocumentWithMode(completed.root, contribution.destination, PlacementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil { if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)} return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)}
} }
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem) placementNodes := destinationWrapperNodeCountForMode(contribution.destination, PlacementModeSequenceItem)
if !contribution.placementCharged { if !contribution.placementCharged {
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes { if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)} return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)}

View File

@@ -26,7 +26,6 @@ type selectedImportCandidate struct {
// importSelection contains lexical candidates only; admission assigns source identity later. // importSelection contains lexical candidates only; admission assigns source identity later.
type importSelection struct { type importSelection struct {
candidates []selectedImportCandidate candidates []selectedImportCandidate
outcome importSelectionOutcome
} }
func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) { func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec importDeclarationSpec, policy resolvedImportPolicy) (importSelection, []Diagnostic) {
@@ -68,9 +67,6 @@ func selectNamedImportCandidates(access *filesystemAccess, basePath string, spec
candidates = append(candidates, candidate) candidates = append(candidates, candidate)
} }
selection := importSelection{candidates: candidates} selection := importSelection{candidates: candidates}
if len(candidates) != 0 {
selection.outcome = importSelectionHasCandidates
}
return selection, nil return selection, nil
} }
@@ -174,9 +170,6 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")} return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
} }
selection := importSelection{candidates: selected} selection := importSelection{candidates: selected}
if len(selected) != 0 {
selection.outcome = importSelectionHasCandidates
}
return selection, nil return selection, nil
} }
@@ -299,7 +292,7 @@ func importDirectoryEntries(access *filesystemAccess, directory string, site imp
if errors.Is(err, errImportDirectoryEntryLimit) { if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit") return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
} }
if errors.Is(err, fs.ErrNotExist) { if missingDirectoryOpen(err) {
return nil, true, nil return nil, true, nil
} }
if err != nil { if err != nil {

View File

@@ -30,7 +30,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("optional partial selection diagnostics = %#v", diagnostics) t.Fatalf("optional partial selection diagnostics = %#v", diagnostics)
} }
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" { if len(selection.candidates) != 1 || filepath.Base(selection.candidates[0].path) != "present.yaml" {
t.Fatalf("partial selection = %#v", selection) t.Fatalf("partial selection = %#v", selection)
} }
@@ -48,7 +48,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true}) empty, diagnostics := selectNamedImportCandidates(access, root, emptySpecs[0], resolvedImportPolicy{required: true})
if len(diagnostics) != 0 || empty.outcome != importSelectionNone || len(empty.candidates) != 0 { if len(diagnostics) != 0 || len(empty.candidates) != 0 {
t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics) t.Fatalf("required empty path selection, diagnostics = %#v, %#v", empty, diagnostics)
} }
} }
@@ -67,7 +67,7 @@ func TestSelectNamedImportsChecksRequiredTargetsBeforeExclusions(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
@@ -118,12 +118,12 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy) selection, diagnostics := selectNamedImportCandidates(access, ".", specs[0], policy)
if len(diagnostics) != 0 || selection.outcome != importSelectionNone { if len(diagnostics) != 0 || len(selection.candidates) != 0 {
t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics) t.Fatalf("excluded selection, diagnostics = %#v, %#v", selection, diagnostics)
} }
if filesystem.openCalls != 0 || filesystem.statCalls == 0 { if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
@@ -135,7 +135,7 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
hidden := false hidden := false
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], importDefaults{hidden: &hidden}) privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], ImportDefaults{Hidden: &hidden})
if len(policyDiagnostics) != 0 || !privatePolicy.hidden { if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics) t.Fatalf("path selector accepted search-only defaults: %#v, %#v", privatePolicy, policyDiagnostics)
} }
@@ -161,8 +161,21 @@ func TestSelectNamedImportsChecksNormalizedPathsLexically(t *testing.T) {
if len(diagnostics) != 0 || len(selection.candidates) != 2 { if len(diagnostics) != 0 || len(selection.candidates) != 2 {
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics) t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
} }
if strings.Join(filesystem.statPaths, ",") != "a.yaml,a.yaml,z.yaml,z.yaml" { if selection.candidates[0].path != "a.yaml" || selection.candidates[1].path != "z.yaml" {
t.Fatalf("target stat order = %#v", filesystem.statPaths) t.Fatalf("candidate order = %#v, want a.yaml then z.yaml", selection.candidates)
}
failingFilesystem := &statOnlyImportFilesystem{statErrors: map[string]error{
"a.yaml": fs.ErrPermission,
"z.yaml": fs.ErrPermission,
}}
failingAccess := newNativeAccess(failingFilesystem, ".", &namespaceToken{})
_, diagnostics = selectNamedImportCandidates(failingAccess, ".", specs[0], resolvedImportPolicy{})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportInvalid || diagnostics[0].Path.String() != "/configkit/imports/0/path/1" {
t.Fatalf("first lexical failure = %#v, want /configkit/imports/0/path/1", diagnostics)
}
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-1" {
t.Fatalf("first lexical failure location = %#v", diagnostics[0].Primary)
} }
} }
@@ -226,7 +239,6 @@ type statOnlyImportFilesystem struct {
statErrors map[string]error statErrors map[string]error
openCalls int openCalls int
statCalls int statCalls int
statPaths []string
} }
func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) { func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
@@ -236,7 +248,6 @@ func (filesystem *statOnlyImportFilesystem) Open(name string) (fs.File, error) {
func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) { func (filesystem *statOnlyImportFilesystem) Stat(name string) (fs.FileInfo, error) {
filesystem.statCalls++ filesystem.statCalls++
filesystem.statPaths = append(filesystem.statPaths, name)
if err := filesystem.statErrors[name]; err != nil { if err := filesystem.statErrors[name]; err != nil {
return nil, err return nil, err
} }

View File

@@ -35,23 +35,13 @@ type importDeclarationSpec struct {
pattern []string pattern []string
patternSet bool patternSet bool
exclude []string exclude []string
excludeSet bool
depth int depth int
depthSet bool depthSet bool
destination Path destination Path
placement placementMode placement PlacementMode
fieldSites map[string]importFieldSite fieldSites map[string]importFieldSite
} }
// importDefaults are the engine's private copy of load-wide discovery defaults.
type importDefaults struct {
required *bool
follow *bool
hidden *bool
recursive *bool
pattern []string
}
// ImportDefaults transports root-owned load defaults across the facade // ImportDefaults transports root-owned load defaults across the facade
// boundary. Load options snapshot this value before source acquisition. // boundary. Load options snapshot this value before source acquisition.
type ImportDefaults struct { type ImportDefaults struct {
@@ -66,26 +56,6 @@ type ImportDefaults struct {
// Pattern filters directory-search filenames; nil and empty differ. // Pattern filters directory-search filenames; nil and empty differ.
Pattern []string Pattern []string
} }
// OptionsWithImportDefaults copies root-owned defaults into ordinary options.
func OptionsWithImportDefaults[T any](options Options[T], value ImportDefaults) Options[T] {
options.importDefaults = privateImportDefaults(value)
return options
}
// LayeredOptionsWithImportDefaults copies root-owned defaults into grouped options.
func LayeredOptionsWithImportDefaults[T any](options LayeredOptions[T], value ImportDefaults) LayeredOptions[T] {
options.importDefaults = privateImportDefaults(value)
return options
}
func privateImportDefaults(value ImportDefaults) importDefaults {
return snapshotImportDefaults(importDefaults{
required: value.Required, follow: value.Follow, hidden: value.Hidden,
recursive: value.Recursive, pattern: value.Pattern,
})
}
type resolvedImportPolicy struct { type resolvedImportPolicy struct {
required bool required bool
follow bool follow bool
@@ -96,16 +66,16 @@ type resolvedImportPolicy struct {
depthSet bool depthSet bool
exclude []string exclude []string
destination Path destination Path
placement placementMode placement PlacementMode
} }
func snapshotImportDefaults(value importDefaults) importDefaults { func snapshotImportDefaults(value ImportDefaults) ImportDefaults {
return importDefaults{ return ImportDefaults{
required: cloneBool(value.required), Required: cloneBool(value.Required),
follow: cloneBool(value.follow), Follow: cloneBool(value.Follow),
hidden: cloneBool(value.hidden), Hidden: cloneBool(value.Hidden),
recursive: cloneBool(value.recursive), Recursive: cloneBool(value.Recursive),
pattern: cloneStrings(value.pattern), Pattern: cloneStrings(value.Pattern),
} }
} }
@@ -124,8 +94,8 @@ func cloneStrings(values []string) []string {
return append(make([]string, 0, len(values)), values...) return append(make([]string, 0, len(values)), values...)
} }
func validateImportDefaults(value importDefaults) []Diagnostic { func validateImportDefaults(value ImportDefaults) []Diagnostic {
for _, pattern := range value.pattern { for _, pattern := range value.Pattern {
if _, err := path.Match(pattern, ""); err != nil { if _, err := path.Match(pattern, ""); err != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")} return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")}
} }
@@ -133,10 +103,9 @@ func validateImportDefaults(value importDefaults) []Diagnostic {
return nil return nil
} }
func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (resolvedImportPolicy, []Diagnostic) { func resolveImportPolicy(spec importDeclarationSpec, defaults ImportDefaults) (resolvedImportPolicy, []Diagnostic) {
defaults = snapshotImportDefaults(defaults)
policy := resolvedImportPolicy{ policy := resolvedImportPolicy{
required: boolOr(spec.required, defaults.required, false), required: boolOr(spec.required, defaults.Required, false),
follow: true, follow: true,
hidden: true, hidden: true,
recursive: false, recursive: false,
@@ -147,15 +116,15 @@ func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (r
exclude: cloneStrings(spec.exclude), exclude: cloneStrings(spec.exclude),
} }
if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob { if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob {
policy.follow = boolOr(spec.follow, defaults.follow, true) policy.follow = boolOr(spec.follow, defaults.Follow, true)
policy.hidden = boolOr(spec.hidden, defaults.hidden, true) policy.hidden = boolOr(spec.hidden, defaults.Hidden, true)
} }
if spec.selector == importSelectorDirectory { if spec.selector == importSelectorDirectory {
policy.recursive = boolOr(spec.recursive, defaults.recursive, false) policy.recursive = boolOr(spec.recursive, defaults.Recursive, false)
if spec.patternSet { if spec.patternSet {
policy.pattern = cloneStrings(spec.pattern) policy.pattern = cloneStrings(spec.pattern)
} else { } else {
policy.pattern = cloneStrings(defaults.pattern) policy.pattern = cloneStrings(defaults.Pattern)
} }
} }
if spec.depthSet && !policy.recursive { if spec.depthSet && !policy.recursive {
@@ -180,26 +149,6 @@ func boolOr(explicit, application *bool, fallback bool) bool {
return fallback return fallback
} }
// parseImportSpecificationDirective validates every declaration before callers select candidates.
func parseImportSpecificationDirective(node *document.Node, directivePath Path) ([]importDeclarationSpec, []Diagnostic) {
if node.Kind != document.Mapping {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
}
var specs []importDeclarationSpec
var diagnostics []Diagnostic
for _, entry := range node.Mapping {
fieldPath := directivePath.Key(entry.Key)
if entry.Key != "imports" {
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value))
continue
}
parsed, issues := parseImportDeclarations(entry.Value, fieldPath)
specs = append(specs, parsed...)
diagnostics = append(diagnostics, issues...)
}
return specs, canonicalDiagnostics(diagnostics)
}
func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) { func parseImportDeclarations(node *document.Node, declarationPath Path) ([]importDeclarationSpec, []Diagnostic) {
if node == nil || node.Kind != document.Sequence { if node == nil || node.Kind != document.Sequence {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)} return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "imports must be a sequence", node)}
@@ -224,7 +173,7 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
spec := importDeclarationSpec{ spec := importDeclarationSpec{
path: declarationPath.clone(), path: declarationPath.clone(),
location: publicLocation(node.Location), location: publicLocation(node.Location),
placement: placementModeMapping, placement: PlacementModeMapping,
fieldSites: make(map[string]importFieldSite, len(node.Mapping)), fieldSites: make(map[string]importFieldSite, len(node.Mapping)),
} }
selectors := 0 selectors := 0
@@ -327,24 +276,24 @@ func parseImportWildcardAssignment(spec *importDeclarationSpec, field string, no
case "pattern": case "pattern":
spec.pattern, spec.patternSet = values, true spec.pattern, spec.patternSet = values, true
case "exclude": case "exclude":
spec.exclude, spec.excludeSet = values, true spec.exclude = values
} }
return nil return nil
} }
func parseImportPlacement(node *document.Node, fieldPath Path) (placementMode, *Diagnostic) { func parseImportPlacement(node *document.Node, fieldPath Path) (PlacementMode, *Diagnostic) {
if node.Kind != document.String { if node.Kind != document.String {
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node) diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
return placementModeMapping, &diagnostic return PlacementModeMapping, &diagnostic
} }
switch node.Text { switch node.Text {
case "mapping": case "mapping":
return placementModeMapping, nil return PlacementModeMapping, nil
case "sequence-item": case "sequence-item":
return placementModeSequenceItem, nil return PlacementModeSequenceItem, nil
default: default:
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node) diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
return placementModeMapping, &diagnostic return PlacementModeMapping, &diagnostic
} }
} }

View File

@@ -31,14 +31,14 @@ func TestParseImportDeclarationSpecNormalizesPresenceAndPlacement(t *testing.T)
if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" { if first.selector != importSelectorPath || len(first.paths) != 2 || first.paths[0] != "one.yaml" || first.paths[1] != "two.yaml" {
t.Fatalf("path selector = %#v", first) t.Fatalf("path selector = %#v", first)
} }
if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != placementModeSequenceItem { if first.required == nil || *first.required || first.destination.String() != "/services" || first.placement != PlacementModeSequenceItem {
t.Fatalf("explicit declaration values = %#v", first) t.Fatalf("explicit declaration values = %#v", first)
} }
if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" { if first.path.String() != "/configkit/imports/0" || first.fieldSites["required"].path.String() != "/configkit/imports/0/required" {
t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String()) t.Fatalf("stored paths = declaration %q, required %q", first.path.String(), first.fieldSites["required"].path.String())
} }
second := specs[1] second := specs[1]
if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || !second.excludeSet || second.exclude == nil { if second.selector != importSelectorDirectory || second.paths == nil || len(second.paths) != 0 || !second.patternSet || second.pattern == nil || len(second.pattern) != 0 || second.exclude == nil {
t.Fatalf("empty list presence was lost: %#v", second) t.Fatalf("empty list presence was lost: %#v", second)
} }
third := specs[2] third := specs[2]
@@ -123,13 +123,13 @@ func TestParseImportDeclarationSpecValidatesDepthSyntaxAndPolicy(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics) t.Fatalf("syntax-only parse diagnostics = %#v", diagnostics)
} }
_, diagnostics = resolveImportPolicy(specs[0], importDefaults{}) _, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" { if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/configkit/imports/0/depth" {
t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics) t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics)
} }
recursive := true recursive := true
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{recursive: &recursive}) policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{Recursive: &recursive})
if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet { if len(diagnostics) != 0 || !policy.recursive || policy.depth != 1 || !policy.depthSet {
t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics) t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics)
} }
@@ -142,7 +142,7 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], importDefaults{}) omitted, diagnostics := resolveImportPolicy(omittedSpecs[0], ImportDefaults{})
if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil { if len(diagnostics) != 0 || omitted.required || omitted.recursive || !omitted.follow || !omitted.hidden || omitted.pattern != nil {
t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics) t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics)
} }
@@ -159,12 +159,12 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
t.Fatal(diagnostics) t.Fatal(diagnostics)
} }
appRequired, appRecursive, appFollow, appHidden := true, true, true, true appRequired, appRecursive, appFollow, appHidden := true, true, true, true
defaults := importDefaults{ defaults := ImportDefaults{
required: &appRequired, Required: &appRequired,
recursive: &appRecursive, Recursive: &appRecursive,
follow: &appFollow, Follow: &appFollow,
hidden: &appHidden, Hidden: &appHidden,
pattern: []string{"*.yaml"}, Pattern: []string{"*.yaml"},
} }
policy, diagnostics := resolveImportPolicy(specs[0], defaults) policy, diagnostics := resolveImportPolicy(specs[0], defaults)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -188,16 +188,24 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
} }
snapshot := snapshotImportDefaults(defaults) snapshot := snapshotImportDefaults(defaults)
*defaults.required = false *defaults.Required = false
defaults.pattern[0] = "*.toml" *defaults.Follow = false
if snapshot.required == nil || !*snapshot.required || snapshot.pattern[0] != "*.yaml" { *defaults.Hidden = false
*defaults.Recursive = false
defaults.Pattern[0] = "*.toml"
if snapshot.Required == nil || !*snapshot.Required || snapshot.Follow == nil || !*snapshot.Follow ||
snapshot.Hidden == nil || !*snapshot.Hidden || snapshot.Recursive == nil || !*snapshot.Recursive || snapshot.Pattern[0] != "*.yaml" {
t.Fatalf("snapshot shares mutable inputs: %#v", snapshot) t.Fatalf("snapshot shares mutable inputs: %#v", snapshot)
} }
empty := snapshotImportDefaults(importDefaults{pattern: []string{}}) omittedSnapshot := snapshotImportDefaults(ImportDefaults{})
if empty.pattern == nil || len(empty.pattern) != 0 { if omittedSnapshot.Required != nil || omittedSnapshot.Follow != nil || omittedSnapshot.Hidden != nil || omittedSnapshot.Recursive != nil {
t.Fatal("snapshot changed omitted booleans into explicit values")
}
empty := snapshotImportDefaults(ImportDefaults{Pattern: []string{}})
if empty.Pattern == nil || len(empty.Pattern) != 0 {
t.Fatalf("snapshot lost explicit empty pattern: %#v", empty) t.Fatalf("snapshot lost explicit empty pattern: %#v", empty)
} }
if snapshotImportDefaults(importDefaults{}).pattern != nil { if snapshotImportDefaults(ImportDefaults{}).Pattern != nil {
t.Fatal("snapshot changed omitted pattern into explicit empty") t.Fatal("snapshot changed omitted pattern into explicit empty")
} }
} }
@@ -207,7 +215,7 @@ func TestInvalidImportDefaultPatternsFailBeforeReadingSource(t *testing.T) {
reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")} reader := &countingImportSpecReader{Reader: strings.NewReader("not: read\n")}
_, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{ _, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{
importDefaults: importDefaults{pattern: []string{"["}}, ImportDefaults: ImportDefaults{Pattern: []string{"["}},
}) })
var loadErr *LoadError var loadErr *LoadError
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid { if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
@@ -224,11 +232,11 @@ func parseImportSpecsYAML(t *testing.T, declarations string) ([]importDeclaratio
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
} }
index := documentEntryIndex(parsed.document.Mapping, directiveKey) extracted, diagnostics := extractRootDirectives(parsed.document, rootDirectiveContextSource)
if index < 0 { if len(diagnostics) != 0 {
t.Fatal("missing root directive in test fixture") return nil, diagnostics
} }
return parseImportSpecificationDirective(parsed.document.Mapping[index].Value, Keys(directiveKey)) return extracted.imports, nil
} }
type countingImportSpecReader struct { type countingImportSpecReader struct {

View File

@@ -113,17 +113,22 @@ tasks: [{id: z-parent}]
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n") aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n") bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}})
input, diagnostics := testAcquireInput(inputAcquisitionRequest{ input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain, input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
directiveContext: rootDirectiveContextImportsOnly, directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{ }, acquisitionPolicy{
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}), sequencePolicy: sequencePolicy,
importLimits: defaultImportLimits(), importLimits: defaultImportLimits(),
}, nil) }, nil)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble imports: %#v", diagnostics) t.Fatalf("assemble imports: %#v", diagnostics)
} }
tasks, ok := documentAtPath(input.document, Keys("tasks")) composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy)
if len(diagnostics) != 0 {
t.Fatalf("compose acquired imports: %#v", diagnostics)
}
tasks, ok := documentAtPath(composition.root, Keys("tasks"))
if !ok || tasks.Kind != document.Sequence { if !ok || tasks.Kind != document.Sequence {
t.Fatalf("tasks document = %#v, %v", tasks, ok) t.Fatalf("tasks document = %#v, %v", tasks, ok)
} }
@@ -408,7 +413,7 @@ label: helper
loaded, err := LoadLayered( loaded, err := LoadLayered(
Input{Source: NoSource(), Imports: true}, testSecretBinding(), Input{Source: NoSource(), Imports: true}, testSecretBinding(),
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem, Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem,
}}}}}, }}}}},
) )
if err != nil { if err != nil {
@@ -498,7 +503,7 @@ func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testi
t.Fatalf("config = %#v", result.Config) t.Fatalf("config = %#v", result.Config)
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != placementModeSequenceItem || sources[2].placementMode != placementModeMapping || sources[2].Destination.String() != "/other" { if len(sources) != 3 || sources[1].Path != canonicalTestPath(t, emptyPath) || sources[1].Destination.String() != "/items" || sources[1].placementMode != PlacementModeSequenceItem || sources[2].placementMode != PlacementModeMapping || sources[2].Destination.String() != "/other" {
t.Fatalf("source placement evidence = %#v", sources) t.Fatalf("source placement evidence = %#v", sources)
} }
} }
@@ -516,7 +521,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: helper.yml\nid: main\n")
helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n") helperPath := writeImportFile(t, directory, "helper.yml", "value: helper\n")
result, err := testLoad[config](File(mainPath), Options[config]{ result, err := testLoad[config](File(mainPath), Options[config]{
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem, Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
}) })
if err != nil { if err != nil {
t.Fatalf("load main item: %v", err) t.Fatalf("load main item: %v", err)
@@ -525,7 +530,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
t.Fatalf("main item = %#v", result.Config.Items) t.Fatalf("main item = %#v", result.Config.Items)
} }
sources := result.Sources() sources := result.Sources()
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != placementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != placementModeMapping { if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].placementMode != PlacementModeSequenceItem || sources[1].Destination.String() != "/items/0" || sources[1].placementMode != PlacementModeMapping {
t.Fatalf("main item sources = %#v", sources) t.Fatalf("main item sources = %#v", sources)
} }
if sources[1].Path != canonicalTestPath(t, helperPath) { if sources[1].Path != canonicalTestPath(t, helperPath) {
@@ -544,7 +549,7 @@ func TestProjectAcquiredDestinationsUsesMappedSequenceItemIndex(t *testing.T) {
items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}},
}}} }}}
scope := acquisitionScope{contributions: []acquiredContribution{{ scope := acquisitionScope{contributions: []acquiredContribution{{
source: SourceInfo{ID: "outer-source", placementMode: placementModeSequenceItem}, source: SourceInfo{ID: "outer-source", placementMode: PlacementModeSequenceItem},
destination: Keys("records"), nested: child, destination: Keys("records"), nested: child,
}}} }}}
sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}} sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}}
@@ -627,7 +632,7 @@ id: item
accounting := &importAccounting{} accounting := &importAccounting{}
input, diagnostics := testAcquireInput(inputAcquisitionRequest{ input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: directory}}, input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: directory}},
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly, deferAssembly: true, role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
}, acquisitionPolicy{importLimits: defaultImportLimits()}, accounting) }, acquisitionPolicy{importLimits: defaultImportLimits()}, accounting)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("acquire input: %#v", diagnostics) t.Fatalf("acquire input: %#v", diagnostics)
@@ -683,6 +688,7 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
`) `)
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n") writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
writeImportFile(t, directory, "helper.yml", "id: helper-id\n") writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}})
input, diagnostics := testAcquireInput(inputAcquisitionRequest{ input, diagnostics := testAcquireInput(inputAcquisitionRequest{
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain, input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
directiveContext: rootDirectiveContextImportsOnly, directiveContext: rootDirectiveContextImportsOnly,
@@ -690,8 +696,12 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble item import: %#v", diagnostics) t.Fatalf("assemble item import: %#v", diagnostics)
} }
imported, diagnostics := compositionForInputWithPolicy(input, sequencePolicy)
if len(diagnostics) != 0 {
t.Fatalf("compose acquired item import: %#v", diagnostics)
}
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n") authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
composed, diagnostics := composeDocumentsWithPolicy(mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}), authored, input.document) composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy, authored, imported.root)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("compose authored and imported lists: %#v", diagnostics) t.Fatalf("compose authored and imported lists: %#v", diagnostics)
} }
@@ -750,7 +760,7 @@ func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
- glob: records/*.yml - glob: records/*.yml
placement: sequence-item placement: sequence-item
`) `)
if len(declarations) != 2 || declarations[0].placement != placementModeMapping || declarations[1].placement != placementModeSequenceItem { if len(declarations) != 2 || declarations[0].placement != PlacementModeMapping || declarations[1].placement != PlacementModeSequenceItem {
t.Fatalf("declaration placements = %#v", declarations) t.Fatalf("declaration placements = %#v", declarations)
} }
} }
@@ -874,7 +884,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n") writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n")
_, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{ _, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Imports: true, importDefaults: importDefaults{required: &required}, Imports: true, ImportDefaults: ImportDefaults{Required: &required},
}) })
var loadError *LoadError var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
@@ -886,7 +896,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
_, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{ _, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{
main: loadInput{source: File(basePath)}, main: loadInput{source: File(basePath)},
underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}}, underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}},
importDefaults: importDefaults{required: &required}, importDefaults: ImportDefaults{Required: &required},
}, testSecretBinding(), func(string) (string, bool) { return "", false }) }, testSecretBinding(), func(string) (string, bool) { return "", false })
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing { if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("grouped underlay required default error = %#v", err) t.Fatalf("grouped underlay required default error = %#v", err)
@@ -998,10 +1008,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
break break
} }
} }
if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.nested == nil { if item == nil || item.identity != graph.nodes[1].key || item.scopeOwner != rootScope.owner || item.pendingItem == nil {
t.Fatalf("outer item contribution = %#v", item) t.Fatalf("outer item contribution = %#v", item)
} }
itemScope := item.nested.acquisition[0] itemScope := item.pendingItem
if itemScope.owner != item.identity || len(itemScope.contributions) != 2 { if itemScope.owner != item.identity || len(itemScope.contributions) != 2 {
t.Fatalf("nested item scope = %#v", itemScope) t.Fatalf("nested item scope = %#v", itemScope)
} }
@@ -1097,7 +1107,7 @@ id: item
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n") mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
writeImportFile(t, directory, "helper.yml", "id: helper\n") writeImportFile(t, directory, "helper.yml", "id: helper\n")
options := Options[importTestConfig]{ options := Options[importTestConfig]{
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem, Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
} }
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict) loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
diagnostic := loadError.Diagnostics()[0] diagnostic := loadError.Diagnostics()[0]
@@ -1190,8 +1200,12 @@ func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("assemble item scope: %#v", diagnostics) t.Fatalf("assemble item scope: %#v", diagnostics)
} }
composition, diagnostics := compositionForInputWithPolicy(input, sequencePolicy{})
if len(diagnostics) != 0 {
t.Fatalf("compose acquired item scope: %#v", diagnostics)
}
var nested *composedDocument var nested *composedDocument
for _, contribution := range input.composition.acquisition[0].contributions { for _, contribution := range composition.acquisition[0].contributions {
if contribution.role == importRoleItem { if contribution.role == importRoleItem {
nested = contribution.nested nested = contribution.nested
break break
@@ -1398,15 +1412,15 @@ func TestImportDepthBudgetCountsSelectedEdges(t *testing.T) {
limits.depth = 1 limits.depth = 1
main := parseImportMain(t, mainPath) main := parseImportMain(t, mainPath)
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil) request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
composed, sources, diagnostics := testAcquireImportWithLimits(request, limits) acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
if test.code != "" { if test.code != "" {
if len(diagnostics) != 1 || diagnostics[0].Code != test.code { if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code) t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
} }
return return
} }
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 { if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
t.Fatalf("depth-boundary composition: sources=%#v diagnostics=%#v", sources, diagnostics) t.Fatalf("depth-boundary acquisition: sources=%#v diagnostics=%#v", sources, diagnostics)
} }
}) })
} }
@@ -1429,9 +1443,9 @@ func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
nodes: countInputNodes(main.document) + countInputNodes(imported.document), nodes: countInputNodes(main.document) + countInputNodes(imported.document),
} }
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil) request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
composed, sources, diagnostics := testAcquireImportWithLimits(request, limits) acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 { if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics) t.Fatalf("exact-limit acquisition = (%#v, %#v, %#v)", acquired, sources, diagnostics)
} }
} }
@@ -1516,6 +1530,14 @@ func testAcquireImport(request importAcquisitionRequest, policy acquisitionPolic
return composition, sources, diagnostics return composition, sources, diagnostics
} }
func testComposeAcquiredImport(request importAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, acquired *composedDocument, sources []SourceInfo) (*composedDocument, []Diagnostic) {
input := assembledInput{
parsed: request.main, composition: acquired, acquisition: cloneAcquisitionScopes(acquired.acquisition),
sources: sources, versionState: state.versionState, accounting: state.accounting,
}
return composeAcquiredInputWithPolicies(input, collectionPolicyScope{}, policy.sequencePolicy)
}
func testAcquireImportWithLimits(request importAcquisitionRequest, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { func testAcquireImportWithLimits(request importAcquisitionRequest, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: &importAccounting{}}, &tierAcquisitionState{}) return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: &importAccounting{}}, &tierAcquisitionState{})
} }

View File

@@ -129,15 +129,11 @@ func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix
return projected return projected
} }
// A replacement retains the displaced source/derivation record while the // A replacement retains the before and after values for history accounting.
// effective origin belongs to the child. Value trees are borrowed immutable // The trees are borrowed immutable inputs or detached templates.
// inputs or detached templates and are budgeted with retained history later.
type inheritanceReplacement struct { type inheritanceReplacement struct {
path Path before *document.Node
before *document.Node after *document.Node
after *document.Node
previous []displacedDefinition
effective Origin
} }
// A link represents one shared relationship edge. Each contributor retains // A link represents one shared relationship edge. Each contributor retains
@@ -173,11 +169,6 @@ func inheritanceDirective(node *document.Node) (document.Entry, bool) {
return document.Entry{}, false return document.Entry{}, false
} }
func inheritanceDirectiveLocation(node *document.Node) Location {
entry, _ := inheritanceDirective(node)
return publicLocation(entry.Value.Location)
}
func withoutInheritanceDirective(node *document.Node) *document.Node { func withoutInheritanceDirective(node *document.Node) *document.Node {
payload := *node payload := *node
payload.Mapping = make([]document.Entry, 0, len(node.Mapping)) payload.Mapping = make([]document.Entry, 0, len(node.Mapping))

View File

@@ -237,10 +237,8 @@ func applyInheritanceSequenceOrigins(
var replacements []inheritanceReplacement var replacements []inheritanceReplacement
if operation == sequenceModeReplace { if operation == sequenceModeReplace {
if lower != nil { if lower != nil {
displaced := displacedOrigins(lower, origins, path)
replacements = append(replacements, inheritanceReplacement{ replacements = append(replacements, inheritanceReplacement{
path: path.clone(), before: lower, after: higher, before: lower, after: higher,
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
}) })
deleteOriginSubtree(origins, path, lower) deleteOriginSubtree(origins, path, lower)
} }
@@ -265,12 +263,10 @@ func applyInheritanceSequenceOrigins(
lowerPath := path.Index(replacement.lowerIndex) lowerPath := path.Index(replacement.lowerIndex)
higherPath := path.Index(replacement.higherIndex) higherPath := path.Index(replacement.higherIndex)
outputPath := path.Index(replacement.outputIndex) outputPath := path.Index(replacement.outputIndex)
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex]) deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath) copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath)
replacements = append(replacements, inheritanceReplacement{ replacements = append(replacements, inheritanceReplacement{
path: outputPath, before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex], before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
}) })
} }
for _, projection := range combined.higherItems { for _, projection := range combined.higherItems {

View File

@@ -55,10 +55,8 @@ func combineInheritanceTemplate(
mergeInheritanceOrigin(mergedOrigins, childPath, child.origins[childPath.String()]) mergeInheritanceOrigin(mergedOrigins, childPath, child.origins[childPath.String()])
}, },
replaced: func(childPath Path, existing, incoming *document.Node) { replaced: func(childPath Path, existing, incoming *document.Node) {
previous := displacedOrigins(existing, mergedOrigins, childPath)
result.replacements = append(result.replacements, inheritanceReplacement{ result.replacements = append(result.replacements, inheritanceReplacement{
path: childPath.clone(), before: existing, after: incoming, before: existing, after: incoming,
previous: previous, effective: cloneOrigin(child.origins[childPath.String()]),
}) })
deleteOriginSubtree(mergedOrigins, childPath, existing) deleteOriginSubtree(mergedOrigins, childPath, existing)
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath) copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
@@ -87,17 +85,9 @@ func combineInheritanceTemplate(
result.replacements = append(result.replacements, applyInheritanceSequenceOrigins( result.replacements = append(result.replacements, applyInheritanceSequenceOrigins(
mergedOrigins, child.origins, childPath, lowerValue, incoming, combined, resolution.operation, mergedOrigins, child.origins, childPath, lowerValue, incoming, combined, resolution.operation,
)...) )...)
history := sequenceHistoryContribution{path: childPath.clone(), mode: sequenceHistoryMapped} sequenceContributions = append(sequenceContributions, sequenceHistoryContributionForCombination(
if resolution.operation == sequenceModeReplace { childPath, resolution.operation, incoming, combined,
history.mode = sequenceHistoryReplace ))
if incoming != nil && incoming.Kind == document.Sequence {
history.length = len(incoming.Sequence)
}
} else {
history.items = append([]sequenceItemProjection(nil), combined.higherItems...)
history.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
}
sequenceContributions = append(sequenceContributions, history)
return combined.value, true return combined.value, true
}, },
}) })

View File

@@ -74,7 +74,6 @@ func applyInheritanceMetadata(metadata *Metadata, raw *document.Node, rawOrigins
}) })
applyOverlayMetadata(&sourceView, overlays) applyOverlayMetadata(&sourceView, overlays)
metadata.history = sourceView.history metadata.history = sourceView.history
metadata.inheritance = cloneInheritanceOperations(expansion.operations)
} }
func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change { func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change {

View File

@@ -124,6 +124,7 @@ func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocume
} }
metadata := metadataFromDecoded(decoded) metadata := metadataFromDecoded(decoded)
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded) applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
metadata.inheritance = cloneInheritanceOperations(expanded.operations)
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root) finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
return &Inspection{ return &Inspection{
metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root, metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root,
@@ -388,15 +389,18 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) { func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) {
shared := &document.Node{Kind: document.String, Text: "shared"} shared := &document.Node{Kind: document.String, Text: "shared"}
extra := &document.Node{Kind: document.String, Text: "extra"} extra := &document.Node{Kind: document.String, Text: "extra"}
expansion := &inheritanceScope{ inspection := &Inspection{
root: shared, inheritance: []inheritanceOperation{{parent: shared, effective: shared}},
operations: []inheritanceOperation{{parent: shared, effective: shared}}, metadata: Metadata{inheritance: []inheritanceOperation{{parent: shared, effective: extra}}},
replacements: []inheritanceReplacement{{before: shared, after: extra}},
} }
if !historyNodesWithin(2, nil, nil, nil, nil, nil, expansion) || counter := newRetainedNodeCounter(2)
historyNodesWithin(1, nil, nil, nil, nil, nil, expansion) { if !addInspectionHistory(counter, inspection) || counter.count != 2 {
t.Fatal("shared evidence node accounting is incorrect") t.Fatal("shared evidence node accounting is incorrect")
} }
counter = newRetainedNodeCounter(1)
if addInspectionHistory(counter, inspection) {
t.Fatal("distinct inheritance evidence did not consume the budget")
}
} }
func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) { func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) {

View File

@@ -24,6 +24,83 @@ func parseIdentifiedInheritanceSequence(t *testing.T, sourceID, input string) *d
return sequence return sequence
} }
func TestPreserveInheritanceIdentityIndexesCompoundFields(t *testing.T) {
t.Parallel()
mergedEntries := []document.Entry{
{Key: "before", Value: &document.Node{Kind: document.String, Text: "keep"}},
{Key: "id", KeyLocation: document.Location{SourceID: "parent", Line: 2}, ValueLocation: document.Location{SourceID: "parent", Line: 2}, Value: &document.Node{Kind: document.String, Text: "parent-id"}},
}
for index := 0; index < 64; index++ {
key := "setting-" + string(rune('a'+index))
mergedEntries = append(mergedEntries, document.Entry{Key: key, Value: &document.Node{Kind: document.String, Text: key}})
}
mergedEntries = append(mergedEntries,
document.Entry{Key: "a/b~c", KeyLocation: document.Location{SourceID: "parent", Line: 70}, ValueLocation: document.Location{SourceID: "parent", Line: 70}, Value: &document.Node{Kind: document.String, Text: "parent-literal"}},
document.Entry{Key: "missing", Value: &document.Node{Kind: document.String, Text: "retained"}},
document.Entry{Key: "after", Value: &document.Node{Kind: document.String, Text: "keep"}},
)
merged := &document.Node{Kind: document.Mapping, Mapping: mergedEntries}
child := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "a/b~c", KeyLocation: document.Location{SourceID: "child", Line: 9, Column: 3}, ValueLocation: document.Location{SourceID: "child", Line: 9, Column: 10}, Value: &document.Node{Kind: document.String, Text: "child-literal", Location: document.Location{SourceID: "child", Line: 9, Column: 10}}},
{Key: "id", KeyLocation: document.Location{SourceID: "child", Line: 3, Column: 3}, ValueLocation: document.Location{SourceID: "child", Line: 3, Column: 7}, Value: &document.Node{Kind: document.String, Text: "child-id", Location: document.Location{SourceID: "child", Line: 3, Column: 7}}},
}}
beforeKeys := make([]string, len(merged.Mapping))
for index, entry := range merged.Mapping {
beforeKeys[index] = entry.Key
}
untouched := merged.Mapping[2].Value
preserveInheritanceIdentityFields(merged, child, []string{"id", "a/b~c", "missing"})
for index, entry := range merged.Mapping {
if entry.Key != beforeKeys[index] {
t.Fatalf("mapping order changed at %d: %q, want %q", index, entry.Key, beforeKeys[index])
}
}
for _, want := range []struct {
key string
text string
keyLine int
keyColumn int
valueLine int
valueColumn int
}{
{key: "id", text: "child-id", keyLine: 3, keyColumn: 3, valueLine: 3, valueColumn: 7},
{key: "a/b~c", text: "child-literal", keyLine: 9, keyColumn: 3, valueLine: 9, valueColumn: 10},
} {
var got document.Entry
for _, entry := range merged.Mapping {
if entry.Key == want.key {
got = entry
break
}
}
if got.Value == nil || got.Value.Text != want.text || got.KeyLocation.SourceID != "child" || got.KeyLocation.Line != want.keyLine || got.KeyLocation.Column != want.keyColumn ||
got.ValueLocation.SourceID != "child" || got.ValueLocation.Line != want.valueLine || got.ValueLocation.Column != want.valueColumn ||
got.Value.Location.SourceID != "child" || got.Value.Location.Line != want.valueLine || got.Value.Location.Column != want.valueColumn {
t.Fatalf("restored identity %q = %#v", want.key, got)
}
if got.Value == child.Mapping[0].Value || (want.key == "id" && got.Value == child.Mapping[1].Value) {
t.Fatalf("restored identity %q borrows its child value", want.key)
}
}
if merged.Mapping[2].Value != untouched {
t.Fatal("nonidentity sibling was replaced")
}
for _, entry := range merged.Mapping {
if entry.Key == "missing" && entry.Value.Text != "retained" {
t.Fatalf("identity absent from child was changed: %#v", entry)
}
}
child.Mapping[0].Value.Text = "mutated"
for _, entry := range merged.Mapping {
if entry.Key == "a/b~c" && entry.Value.Text != "child-literal" {
t.Fatal("restored identity value aliases the child template")
}
}
}
func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) { func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) {
sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2 sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2
tenant: acme tenant: acme
@@ -198,11 +275,6 @@ func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *test
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found { if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
t.Fatal("nested inherited value did not retain its destination origin") t.Fatal("nested inherited value did not retain its destination origin")
} }
for _, replacement := range expanded.replacements {
if replacement.path.String() == "/jobs/0/id" {
t.Fatal("parent identity was treated as payload replaced by the child's identity")
}
}
} }
func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) { func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) {

View File

@@ -44,11 +44,8 @@ func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *tes
if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 { if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 {
t.Fatalf("direct replacement origin = %#v", count) t.Fatalf("direct replacement origin = %#v", count)
} }
if len(result.replacements) != 1 || result.replacements[0].path.String() != "/child/count" || if len(result.replacements) != 1 || result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" {
result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" || t.Fatalf("replacement accounting values = %#v", result.replacements)
len(result.replacements[0].previous) != 1 ||
len(projectInheritanceSteps(result.replacements[0].previous[0].origin)) != 1 {
t.Fatalf("displaced inheritance = %#v", result.replacements)
} }
settings := result.origins["/child/settings"] settings := result.origins["/child/settings"]
if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" || if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" ||

View File

@@ -144,18 +144,24 @@ func preserveInheritanceIdentityFields(merged, child *document.Node, fields []st
if len(fields) == 0 || merged == nil || merged.Kind != document.Mapping || child == nil || child.Kind != document.Mapping { if len(fields) == 0 || merged == nil || merged.Kind != document.Mapping || child == nil || child.Kind != document.Mapping {
return return
} }
identityFields := make(map[string]struct{}, len(fields))
for _, field := range fields { for _, field := range fields {
for _, entry := range child.Mapping { identityFields[field] = struct{}{}
if entry.Key != field { }
continue positions := make(map[string]int, len(merged.Mapping))
} for index, entry := range merged.Mapping {
for index := range merged.Mapping { if _, exists := positions[entry.Key]; !exists {
if merged.Mapping[index].Key == field { positions[entry.Key] = index
merged.Mapping[index] = document.Entry{Key: field, Value: document.Clone(entry.Value)} }
break }
} for _, entry := range child.Mapping {
} if _, identity := identityFields[entry.Key]; !identity {
break continue
}
delete(identityFields, entry.Key)
if index, exists := positions[entry.Key]; exists {
entry.Value = document.Clone(entry.Value)
merged.Mapping[index] = entry
} }
} }
} }

View File

@@ -129,7 +129,7 @@ func TestPendingItemCompositionAppliesOuterApplicationIdentityConstraints(t *tes
} }
} }
func TestReplacingOuterCollectionRetiresDiscardedItemIdentity(t *testing.T) { func TestStrictCompositionDoesNotCopyDiscardedItemPolicy(t *testing.T) {
t.Parallel() t.Parallel()
outerPath := Keys("items") outerPath := Keys("items")
@@ -148,9 +148,27 @@ func TestReplacingOuterCollectionRetiresDiscardedItemIdentity(t *testing.T) {
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
t.Fatalf("compose replacement: %#v", diagnostics) t.Fatalf("compose replacement: %#v", diagnostics)
} }
policy, ok := composed.collectionPolicies.at(nestedPath) if _, ok := composed.collectionPolicies.at(nestedPath); ok {
if !ok || policy.identityPresent || len(policy.recordOrder) != 0 { t.Fatal("strict composition copied an unclaimed nested policy into its sparse result")
t.Fatalf("discarded nested owner policy = %#v, %v", policy, ok) }
policy, ok := lower.at(nestedPath)
if !ok || !policy.identityPresent || len(policy.recordOrder) != 0 {
t.Fatalf("borrowed lower nested policy changed during composition: %#v, %v", policy, ok)
}
assembly := newAssemblyAccumulator(&composedDocument{
root: parseItemPolicyDocument(t, "lower", "items:\n - prompts:\n - id: old\n"),
collectionPolicies: lower,
origins: make(map[string]Origin),
})
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
input, nil, composed.collectionPolicies, nil, LayerOverlay, sequencePolicy{}, declarations,
); len(diagnostics) != 0 {
t.Fatalf("apply replacement to complete lower assembly: %#v", diagnostics)
}
assembledPolicy, ok := assembly.current.collectionPolicies.at(nestedPath)
if !ok || assembledPolicy.identityPresent || len(assembledPolicy.recordOrder) != 0 {
t.Fatalf("complete assembly retained discarded nested policy: %#v, %v", assembledPolicy, ok)
} }
} }

View File

@@ -8,7 +8,7 @@ type Input struct {
Imports bool Imports bool
Filesystem *FilesystemContext Filesystem *FilesystemContext
Destination Path Destination Path
placementMode placementMode PlacementMode PlacementMode
} }
// Underlay contains strictly composed contributions at one precedence tier. // Underlay contains strictly composed contributions at one precedence tier.
@@ -17,7 +17,7 @@ type Underlay struct{ Inputs []Input }
// LayeredOptions selects lower-priority tiers and final application rules. // LayeredOptions selects lower-priority tiers and final application rules.
type LayeredOptions[T any] struct { type LayeredOptions[T any] struct {
Definition Definition[T] Definition Definition[T]
importDefaults importDefaults ImportDefaults ImportDefaults
SourceVersionMode sourceVersionMode SourceVersionMode sourceVersionMode
Underlays []Underlay Underlays []Underlay
Overlay string Overlay string
@@ -38,10 +38,10 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
request := loadRequest[T]{ request := loadRequest[T]{
main: loadInput{ main: loadInput{
source: main.Source, imports: main.Imports, filesystem: main.Filesystem, source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
destination: main.Destination.clone(), placementMode: main.placementMode, destination: main.Destination.clone(), placementMode: main.PlacementMode,
}, },
definition: options.Definition, sourceVersionMode: options.SourceVersionMode, definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
importDefaults: snapshotImportDefaults(options.importDefaults), importDefaults: options.ImportDefaults,
overlay: options.Overlay, overlay: options.Overlay,
overlayFilesystem: options.OverlayFilesystem, overlayFilesystem: options.OverlayFilesystem,
overrides: append([]Override(nil), options.Overrides...), overrides: append([]Override(nil), options.Overrides...),
@@ -52,7 +52,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
for inputIndex, input := range underlay.Inputs { for inputIndex, input := range underlay.Inputs {
request.underlays[index].inputs[inputIndex] = loadInput{ request.underlays[index].inputs[inputIndex] = loadInput{
source: input.Source, imports: input.Imports, filesystem: input.Filesystem, source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
destination: input.Destination.clone(), placementMode: input.placementMode, destination: input.Destination.clone(), placementMode: input.PlacementMode,
} }
} }
} }

View File

@@ -32,9 +32,9 @@ type Options[T any] struct {
Filesystem *FilesystemContext Filesystem *FilesystemContext
Overrides []Override Overrides []Override
SourceVersionMode sourceVersionMode SourceVersionMode sourceVersionMode
importDefaults importDefaults ImportDefaults ImportDefaults
destination Path Destination Path
placementMode placementMode PlacementMode PlacementMode
} }
// FilesystemContext supplies filesystem roots for imports and overlays. For an // FilesystemContext supplies filesystem roots for imports and overlays. For an
@@ -93,7 +93,6 @@ type Inspection struct {
baseline *document.Node baseline *document.Node
input *document.Node input *document.Node
inputOrigins map[string]Origin inputOrigins map[string]Origin
structuralInput *document.Node
preCLI *document.Node preCLI *document.Node
preCLIOrigins map[string]Origin preCLIOrigins map[string]Origin
postDecodeApps []historyApplication postDecodeApps []historyApplication
@@ -149,6 +148,10 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
} }
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) { func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
return loadWithSecretLookupLimit(source, secrets, lookup, historyNodeLimit, supplied...)
}
func loadWithSecretLookupLimit[T any](source Source, secrets SecretBinding, lookup secretLookup, historyLimit int, supplied ...Options[T]) (*Loaded[T], error) {
if len(supplied) > 1 { if len(supplied) > 1 {
return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}}).loadError() return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}}).loadError()
} }
@@ -158,9 +161,9 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
} }
input, diagnostics := normalizeInput(loadInput{ input, diagnostics := normalizeInput(loadInput{
source: source, imports: options.Imports, filesystem: options.Filesystem, source: source, imports: options.Imports, filesystem: options.Filesystem,
destination: options.destination.clone(), placementMode: options.placementMode, destination: options.Destination.clone(), placementMode: options.PlacementMode,
}, options.Overlay) }, options.Overlay)
defaults, policy, settingsDiagnostics := loadSettings(options.importDefaults, options.Definition, options.SourceVersionMode) defaults, policy, settingsDiagnostics := loadSettings(options.ImportDefaults, options.Definition, options.SourceVersionMode)
diagnostics = append(diagnostics, settingsDiagnostics...) diagnostics = append(diagnostics, settingsDiagnostics...)
diagnostics = append(diagnostics, overlayOptionDiagnostics(options.Overlay)...) diagnostics = append(diagnostics, overlayOptionDiagnostics(options.Overlay)...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -175,14 +178,14 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
sourceVersionMode: options.SourceVersionMode, sourceVersionMode: options.SourceVersionMode,
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, input.destination, options.SourceVersionMode, input.placementMode), effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, input.destination, options.SourceVersionMode, input.placementMode),
} }
return executeLoadRequest(request, secrets, lookup) return executeLoadRequest(request, secrets, lookup, historyLimit)
} }
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) { func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup, historyLimit int) (*Loaded[T], error) {
accumulator := diagnosticAccumulator{} accumulator := diagnosticAccumulator{}
accounting := &importAccounting{} accounting := &importAccounting{}
policy := acquisitionPolicy{ policy := acquisitionPolicy{
importDefaults: snapshotImportDefaults(request.importDefaults), importDefaults: request.importDefaults,
sequencePolicy: request.sequencePolicy, sequencePolicy: request.sequencePolicy,
importLimits: defaultImportLimits(), importLimits: defaultImportLimits(),
} }
@@ -209,7 +212,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
} }
input, diagnostics := acquireInput(inputAcquisitionRequest{ input, diagnostics := acquireInput(inputAcquisitionRequest{
input: request.main, role: sourceVersionMain, directiveContext: directiveContext, input: request.main, role: sourceVersionMain, directiveContext: directiveContext,
protectionRule: request.protectionVersion(), deferAssembly: true, protectionRule: request.protectionVersion(),
ordinaryOverlay: ordinaryOverlay, ordinaryOverlay: ordinaryOverlay,
}, policy, state, &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)}) }, policy, state, &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)})
request.main.source = input.source request.main.source = input.source
@@ -257,52 +260,22 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
} }
request.effectiveVersion = versionState.effectiveRule request.effectiveVersion = versionState.effectiveRule
if request.ordinary && input.parsed.source != nil { if request.ordinary && input.parsed.source != nil {
baseSources := make([]SourceInfo, 0, len(input.sources)) input, diagnostics = completeOrdinaryResourceContexts(input, request.main)
for _, source := range input.sources { if len(diagnostics) != 0 {
if source.Layer == LayerBase { accumulator.add(diagnostics...)
baseSources = append(baseSources, source) return nil, accumulator.loadError()
}
}
if len(baseSources) != 0 && input.contexts[baseSources[0].ID].FS == nil {
if input.resolvedFilesystem == nil && (request.main.source.kind == sourceFile || request.main.filesystem != nil || nativeContext(request.main.source, request.main.filesystem)) {
resolved, diagnostic := resolveFilesystemContext(request.main.source, request.main.filesystem, input.parsed, nil)
if diagnostic != nil {
accumulator.add(*diagnostic)
return nil, accumulator.loadError()
}
input.resolvedFilesystem = &resolved
}
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, baseSources)
if diagnostic != nil {
accumulator.add(*diagnostic)
return nil, accumulator.loadError()
}
mergeResourceContexts(&input.contexts, contexts)
} }
} }
if input.document != nil && containsInheritanceDirective(input.document) { var expanded bool
composition, problems := compositionForInput(input) var effectiveVersion *VersionRule
if len(problems) != 0 { input, effectiveVersion, expanded, diagnostics = expandStructuralInheritance(input, request.protectionVersion(), policy.sequencePolicy)
accumulator.add(problems...) if len(diagnostics) != 0 {
return nil, accumulator.loadError() accumulator.add(diagnostics...)
} return nil, accumulator.loadError()
expanded, problems := expandInheritanceWithCollectionRules( }
composition.root, composition.origins, composition.collectionPolicies, policy.sequencePolicy, defaultProjectionLimits(), if expanded {
)
if len(problems) != 0 {
accumulator.add(problems...)
return nil, accumulator.loadError()
}
effectiveVersion, problem := projectInheritanceVersion(request.protectionVersion(), expanded)
if problem != nil {
accumulator.add(*problem)
return nil, accumulator.loadError()
}
request.effectiveVersion = effectiveVersion request.effectiveVersion = effectiveVersion
versionState.effectiveRule = effectiveVersion versionState.effectiveRule = effectiveVersion
input.composition = composition
input.inheritance = expanded
input.document = expanded.root
} }
base := overridePreparationInput{root: input.document} base := overridePreparationInput{root: input.document}
@@ -342,8 +315,16 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
if hasErrors(diagnostics) { if hasErrors(diagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
historyApplications := append(cloneHistoryApplications(resolved.applications), cloneHistoryApplications(resolved.postDecodeApps)...) counter := newRetainedNodeCounter(historyLimit)
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, historyApplications, resolved.snapshots, input.inheritance) { inspection, admitted := prepareInspectionHistory(resolved, counter)
if !admitted {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError()
}
historyApplications := make([]historyApplication, 0, len(inspection.applications)+len(inspection.postDecodeApps))
historyApplications = append(historyApplications, inspection.applications...)
historyApplications = append(historyApplications, inspection.postDecodeApps...)
if !addInspectionHistory(counter, inspection) {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"}) accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
@@ -374,5 +355,5 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
resolved.configuration = configuration resolved.configuration = configuration
// Publication detaches equal-valued validator replacements, preserving the // Publication detaches equal-valued validator replacements, preserving the
// validated secret state, then finalizes metadata and sensitivity. // validated secret state, then finalizes metadata and sensitivity.
return publishLoadResult(schema, request.definition, resolved, secretResolution.references, &accumulator) return publishLoadResult(schema, request.definition, resolved, inspection, historyApplications, secretResolution.references, &accumulator)
} }

View File

@@ -107,7 +107,7 @@ func applyInputLayers(source Source, filesystem *FilesystemContext, overlay stri
} }
} }
request := selectedOverlayRequest{ request := selectedOverlayRequest{
name: overlay, filesystem: resolved, protectionRule: protectionRule, name: overlay, protectionRule: protectionRule,
sequencePolicy: policy, limits: defaultImportLimits(), versionState: input.versionState, sequencePolicy: policy, limits: defaultImportLimits(), versionState: input.versionState,
} }
access, diagnostic := openSelectedOverlayAccess(resolved) access, diagnostic := openSelectedOverlayAccess(resolved)
@@ -129,6 +129,55 @@ func applyInputLayers(source Source, filesystem *FilesystemContext, overlay stri
return input, nil return input, nil
} }
func completeOrdinaryResourceContexts(input assembledInput, main loadInput) (assembledInput, []Diagnostic) {
baseSources := make([]SourceInfo, 0, len(input.sources))
for _, source := range input.sources {
if source.Layer == LayerBase {
baseSources = append(baseSources, source)
}
}
if len(baseSources) == 0 || input.contexts[baseSources[0].ID].FS != nil {
return input, nil
}
if input.resolvedFilesystem == nil && (main.source.kind == sourceFile || main.filesystem != nil || nativeContext(main.source, main.filesystem)) {
resolved, diagnostic := resolveFilesystemContext(main.source, main.filesystem, input.parsed, nil)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.resolvedFilesystem = &resolved
}
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, baseSources)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
mergeResourceContexts(&input.contexts, contexts)
return input, nil
}
func expandStructuralInheritance(input assembledInput, protectionRule *VersionRule, policy sequencePolicy) (assembledInput, *VersionRule, bool, []Diagnostic) {
if input.document == nil || !containsInheritanceDirective(input.document) {
return input, nil, false, nil
}
composition, diagnostics := compositionForInput(input)
if len(diagnostics) != 0 {
return input, nil, false, diagnostics
}
expanded, diagnostics := expandInheritanceWithCollectionRules(
composition.root, composition.origins, composition.collectionPolicies, policy, defaultProjectionLimits(),
)
if len(diagnostics) != 0 {
return input, nil, false, diagnostics
}
effectiveVersion, diagnostic := projectInheritanceVersion(protectionRule, expanded)
if diagnostic != nil {
return input, nil, false, []Diagnostic{*diagnostic}
}
input.composition = composition
input.inheritance = expanded
input.document = expanded.root
return input, effectiveVersion, true, nil
}
// compositionForInput initializes the structural handoff used by inheritance // compositionForInput initializes the structural handoff used by inheritance
// expansion. Raw source bytes and mapping keys retain their budgets. // expansion. Raw source bytes and mapping keys retain their budgets.
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) { func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {

View File

@@ -10,7 +10,7 @@ type loadRequest[T any] struct {
main loadInput main loadInput
underlays []loadGroup underlays []loadGroup
definition Definition[T] definition Definition[T]
importDefaults importDefaults importDefaults ImportDefaults
sequencePolicy sequencePolicy sequencePolicy sequencePolicy
sourceVersionMode sourceVersionMode sourceVersionMode sourceVersionMode
effectiveVersion *VersionRule effectiveVersion *VersionRule
@@ -32,7 +32,7 @@ type loadInput struct {
imports bool imports bool
filesystem *FilesystemContext filesystem *FilesystemContext
destination Path destination Path
placementMode placementMode placementMode PlacementMode
} }
type loadGroup struct{ inputs []loadInput } type loadGroup struct{ inputs []loadInput }
@@ -48,6 +48,10 @@ type acquiredUnderlayGroup struct {
} }
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) { func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
return loadGroupedWithSecretLookupLimit(request, secrets, lookup, historyNodeLimit)
}
func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup, historyLimit int) (*Loaded[T], error) {
request.ordinary = false request.ordinary = false
defaults, policy, diagnostics := loadSettings(request.importDefaults, request.definition, request.sourceVersionMode) defaults, policy, diagnostics := loadSettings(request.importDefaults, request.definition, request.sourceVersionMode)
request.importDefaults = defaults request.importDefaults = defaults
@@ -76,7 +80,7 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: canonicalDiagnostics(diagnostics)}).loadError() return nil, (&diagnosticAccumulator{diagnostics: canonicalDiagnostics(diagnostics)}).loadError()
} }
return executeLoadRequest(request, secrets, lookup) return executeLoadRequest(request, secrets, lookup, historyLimit)
} }
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, policy acquisitionPolicy, state *acquisitionState) (assembledInput, []Diagnostic) { func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, policy acquisitionPolicy, state *acquisitionState) (assembledInput, []Diagnostic) {
@@ -109,21 +113,12 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
return input, diagnostics return input, diagnostics
} }
if composition != nil { if composition != nil {
if assembly == nil { _, diagnostics = applyCompletedTier(
assembly = newAssemblyAccumulator(composition) &assembly, composition, composition.collectionPolicies, LayerUnderlay, policy.sequencePolicy,
} else { input.sources[sourceStart:], input.versionState.evidenceSince(evidenceStart),
var declarations []sequenceDeclarationSpec )
for _, scope := range composition.acquisition { if len(diagnostics) != 0 {
declarations = append(declarations, acquiredSequenceDeclarations(scope)...) return input, diagnostics
}
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
composition.root, composition.origins, composition.collectionPolicies, composition.applications, LayerUnderlay,
policy.sequencePolicy, declarations,
)
if len(diagnostics) != 0 {
return input, diagnostics
}
projectTierSourceDestinations(input.sources[sourceStart:], input.versionState.evidenceSince(evidenceStart), operations)
} }
} }
} }
@@ -156,23 +151,14 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
} }
if mainComposition != nil { if mainComposition != nil {
input.versionState.locateMainVersion(mainComposition.root) input.versionState.locateMainVersion(mainComposition.root)
if assembly == nil { operations, diagnostics := applyCompletedTier(
assembly = newAssemblyAccumulator(mainComposition) &assembly, mainComposition, mainComposition.collectionPolicies, LayerBase, policy.sequencePolicy,
} else { input.sources[:mainSourceCount], mainEvidence,
var declarations []sequenceDeclarationSpec )
for _, scope := range mainComposition.acquisition { if len(diagnostics) != 0 {
declarations = append(declarations, acquiredSequenceDeclarations(scope)...) return input, append(versionDiagnostics, diagnostics...)
}
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
mainComposition.root, mainComposition.origins, mainComposition.collectionPolicies, mainComposition.applications, LayerBase,
policy.sequencePolicy, declarations,
)
if len(diagnostics) != 0 {
return input, append(versionDiagnostics, diagnostics...)
}
projectTierSourceDestinations(input.sources[:mainSourceCount], mainEvidence, operations)
input.versionState.projectMainVersion(operations)
} }
input.versionState.projectMainVersion(operations)
} }
if input.versionState != nil { if input.versionState != nil {
request.effectiveVersion = input.versionState.effectiveRule request.effectiveVersion = input.versionState.effectiveRule
@@ -203,7 +189,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
return input, append(versionDiagnostics, *diagnostic) return input, append(versionDiagnostics, *diagnostic)
} }
overlayRequest := selectedOverlayRequest{ overlayRequest := selectedOverlayRequest{
name: request.overlay, filesystem: resolved, protectionRule: request.protectionVersion(), name: request.overlay, protectionRule: request.protectionVersion(),
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState, sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
} }
var diagnostics []Diagnostic var diagnostics []Diagnostic
@@ -226,6 +212,42 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
return input, versionDiagnostics return input, versionDiagnostics
} }
// applyCompletedTier joins one already-composed precedence tier to the load's
// assembly and projects only that tier's source/version evidence through the
// resulting sequence operations. Callers select the tier-local evidence ranges
// so lower-tier and main-tier coordinates remain distinct.
func applyCompletedTier(
assembly **assemblyAccumulator,
composition *composedDocument,
incomingPolicies collectionPolicyScope,
layer Layer,
sequencePolicy sequencePolicy,
incomingSources []SourceInfo,
incomingVersions []*sourceVersionEvidence,
) ([]sequenceHistoryContribution, []Diagnostic) {
if composition == nil {
return nil, nil
}
if *assembly == nil {
*assembly = newAssemblyAccumulator(composition)
return nil, nil
}
var declarations []sequenceDeclarationSpec
for _, scope := range composition.acquisition {
declarations = append(declarations, acquiredSequenceDeclarations(scope)...)
}
operations, diagnostics := (*assembly).applyOverlayWithCollectionProjection(
composition.root, composition.origins, incomingPolicies, composition.applications, layer,
sequencePolicy, declarations,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
projectTierSourceDestinations(incomingSources, incomingVersions, operations)
return operations, nil
}
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) { func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) {
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}} tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
@@ -242,7 +264,7 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
for inputIndex, contribution := range group.inputs { for inputIndex, contribution := range group.inputs {
acquired, diagnostics := acquireInput(inputAcquisitionRequest{ acquired, diagnostics := acquireInput(inputAcquisitionRequest{
input: contribution, role: sourceVersionContribution, input: contribution, role: sourceVersionContribution,
directiveContext: rootDirectiveContextLayered, protectionRule: rule, deferAssembly: true, directiveContext: rootDirectiveContextLayered, protectionRule: rule,
}, policy, state, tier) }, policy, state, tier)
for _, source := range acquired.sources { for _, source := range acquired.sources {
source.Layer = LayerUnderlay source.Layer = LayerUnderlay

View File

@@ -161,8 +161,8 @@ func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
request := loadRequest[map[string]any]{ request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()}, main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{ underlays: []loadGroup{{inputs: []loadInput{
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem}, {source: File(zItem), destination: Keys("items"), placementMode: PlacementModeSequenceItem},
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem}, {source: File(aItem), destination: Keys("items"), placementMode: PlacementModeSequenceItem},
}}}, }}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}, definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
} }
@@ -205,8 +205,8 @@ func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
request := loadRequest[map[string]any]{ request := loadRequest[map[string]any]{
main: loadInput{source: NoSource()}, main: loadInput{source: NoSource()},
underlays: []loadGroup{{inputs: []loadInput{ underlays: []loadGroup{{inputs: []loadInput{
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem}, {source: File(zBad), imports: true, destination: Keys("items"), placementMode: PlacementModeSequenceItem},
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem}, {source: File(aGood), destination: Keys("items"), placementMode: PlacementModeSequenceItem},
}}}, }}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}, definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
} }
@@ -457,21 +457,26 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
accounting := &importAccounting{sources: maxImportSources - 1} accounting := &importAccounting{sources: maxImportSources - 1}
group := loadGroup{inputs: []loadInput{{source: Mapping("first", map[string]any{})}, {source: Mapping("second", map[string]any{})}}} group := loadGroup{inputs: []loadInput{{source: Mapping("first", map[string]any{})}, {source: Mapping("second", map[string]any{})}}}
_, _, _, diagnostics := testAcquireUnderlayGroup(group, 1, nil, accounting) _, diagnostics := testAcquireUnderlayGroup(group, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("shared source budget = %#v", diagnostics) t.Fatalf("shared source budget = %#v", diagnostics)
} }
accounting = &importAccounting{bytes: maxTotalBytes - 2} accounting = &importAccounting{bytes: maxTotalBytes - 2}
group.inputs = []loadInput{{source: Mapping("first", map[string]any{"port": "one"})}} group.inputs = []loadInput{{source: Mapping("first", map[string]any{"port": "one"})}}
_, _, _, diagnostics = testAcquireUnderlayGroup(group, 1, nil, accounting) _, diagnostics = testAcquireUnderlayGroup(group, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded { if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("shared byte budget = %#v", diagnostics) t.Fatalf("shared byte budget = %#v", diagnostics)
} }
} }
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) { func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) {
state := &acquisitionState{accounting: accounting} state := &acquisitionState{accounting: accounting}
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()}) acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()})
return acquired, diagnostics
}
func testComposeUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
acquired, diagnostics := testAcquireUnderlayGroup(group, index, rule, accounting)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, acquired.sources, acquired.contexts, diagnostics return nil, acquired.sources, acquired.contexts, diagnostics
} }
@@ -520,13 +525,13 @@ func TestGroupedAccountingChargesMixedOccurrencesOnce(t *testing.T) {
"child-two.yml": &fstest.MapFile{Data: []byte(child)}, "child-two.yml": &fstest.MapFile{Data: []byte(child)},
}) })
accounting := &importAccounting{} accounting := &importAccounting{}
first, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("one.yml"), imports: true}}}, 1, nil, accounting) first, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("one.yml"), imports: true}}}, 1, nil, accounting)
if len(diagnostics) != 0 || first == nil || len(sources) != 2 || accounting.sources != 2 || accounting.bytes != int64(len(one)+len(child)) { if len(diagnostics) != 0 || first == nil || len(first.sources) != 2 || accounting.sources != 2 || accounting.bytes != int64(len(one)+len(child)) {
t.Fatalf("first acquisition = %#v, %#v, %#v, accounting %#v", first, sources, diagnostics, accounting) t.Fatalf("first acquisition = %#v, %#v, accounting %#v", first, diagnostics, accounting)
} }
second, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("two.yml"), imports: true}}}, 2, nil, accounting) second, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("two.yml"), imports: true}}}, 2, nil, accounting)
if len(diagnostics) != 0 || second == nil || len(sources) != 2 || sources[0].ID != "source-3" || sources[1].ID != "source-4" || accounting.sources != 4 || accounting.bytes != int64(len(one)+len(two)+2*len(child)) { if len(diagnostics) != 0 || second == nil || len(second.sources) != 2 || second.sources[0].ID != "source-3" || second.sources[1].ID != "source-4" || accounting.sources != 4 || accounting.bytes != int64(len(one)+len(two)+2*len(child)) {
t.Fatalf("second acquisition = %#v, %#v, %#v, accounting %#v", second, sources, diagnostics, accounting) t.Fatalf("second acquisition = %#v, %#v, accounting %#v", second, diagnostics, accounting)
} }
var wantNodes int var wantNodes int
for _, text := range []string{one, child, two, child} { for _, text := range []string{one, child, two, child} {
@@ -551,7 +556,7 @@ func TestGroupedGlobEntryBudgetIsShared(t *testing.T) {
"parts/b.yml": &fstest.MapFile{Data: []byte("right: two\n")}, "parts/b.yml": &fstest.MapFile{Data: []byte("right: two\n")},
}) })
accounting := &importAccounting{entries: maxGlobEntries - 1} accounting := &importAccounting{entries: maxGlobEntries - 1}
_, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}, 1, nil, accounting) _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded || accounting.entries <= maxGlobEntries { if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded || accounting.entries <= maxGlobEntries {
t.Fatalf("shared glob budget = %#v, accounting %#v", diagnostics, accounting) t.Fatalf("shared glob budget = %#v, accounting %#v", diagnostics, accounting)
} }

View File

@@ -72,7 +72,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} }
applications := []historyApplication(nil) applications := []historyApplication(nil)
if input.composition != nil { if input.composition != nil {
applications = cloneHistoryApplications(input.composition.applications) applications = input.composition.applications
} else if input.document != nil { } else if input.document != nil {
applications = []historyApplication{{layer: LayerBase, value: input.document}} applications = []historyApplication{{layer: LayerBase, value: input.document}}
} }
@@ -83,8 +83,8 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
return resolvedLoad[T]{ return resolvedLoad[T]{
configuration: configuration, decoded: decoded, metadata: metadata, final: final, configuration: configuration, decoded: decoded, metadata: metadata, final: final,
snapshots: resolutionSnapshots, applications: applications, snapshots: resolutionSnapshots, applications: applications,
postDecodeApps: cloneHistoryApplications(prepared.postDecodeApps), structuralInput: document.Clone(prepared.structuralInput), postDecodeApps: prepared.postDecodeApps, structuralInput: document.Clone(prepared.structuralInput),
preCLI: document.Clone(prepared.preCLI), preCLIOrigins: cloneOrigins(prepared.preCLIOrigins), referenceInput: referenceInput, preCLI: prepared.preCLI, preCLIOrigins: prepared.preCLIOrigins, referenceInput: referenceInput,
versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts, versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts,
}, diagnostics }, diagnostics
} }
@@ -99,7 +99,79 @@ func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, m
return detached.Interface().(T), nil return detached.Interface().(T), nil
} }
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Loaded[T], error) { func prepareInspectionHistory[T any](resolved resolvedLoad[T], counter *retainedNodeCounter) (*Inspection, bool) {
metadata := resolved.metadata
metadata.inheritance = nil
inspection := &Inspection{
metadata: metadata,
baseline: resolved.decoded.baseline,
input: resolved.decoded.final,
inputOrigins: cloneOriginIndex(resolved.decoded.origins),
preCLI: resolved.preCLI,
preCLIOrigins: cloneOrigins(resolved.preCLIOrigins),
final: resolved.final,
resolution: resolved.snapshots,
resolutionReplay: coalescedResolutionSnapshots(resolved.snapshots),
}
if !addInspectionHistory(counter, inspection) {
return nil, false
}
var ok bool
inspection.applications, ok = cloneHistoryApplicationsWithinLimit(resolved.applications, counter)
if !ok {
return nil, false
}
inspection.postDecodeApps, ok = cloneHistoryApplicationsWithinLimit(resolved.postDecodeApps, counter)
if !ok {
return nil, false
}
if resolved.inheritance != nil {
roots := inheritanceOperationDocumentRoots(resolved.inheritance.operations)
if !counter.canAddClones(roots, 2) {
return nil, false
}
inspection.metadata.inheritance = cloneInheritanceOperations(resolved.inheritance.operations)
if !addInheritanceOperationDocuments(counter, inspection.metadata.inheritance) {
return nil, false
}
inspection.inheritance = cloneInheritanceOperations(resolved.inheritance.operations)
if !addInheritanceOperationDocuments(counter, inspection.inheritance) {
return nil, false
}
}
if !addInspectionHistory(counter, inspection) {
return nil, false
}
return inspection, true
}
func cloneHistoryApplicationsWithinLimit(values []historyApplication, counter *retainedNodeCounter) ([]historyApplication, bool) {
for _, value := range values {
if !counter.add(value.value) {
return nil, false
}
}
return cloneHistoryApplications(values), true
}
func inheritanceOperationDocumentRoots(operations []inheritanceOperation) []*document.Node {
roots := make([]*document.Node, 0, len(operations)*2)
for _, operation := range operations {
roots = append(roots, operation.parent, operation.effective)
}
return roots
}
func addInheritanceOperationDocuments(counter *retainedNodeCounter, operations []inheritanceOperation) bool {
for _, operation := range operations {
if !counter.add(operation.parent) || !counter.add(operation.effective) {
return false
}
}
return true
}
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], inspection *Inspection, applications []historyApplication, references map[string]string, accumulator *diagnosticAccumulator) (*Loaded[T], error) {
// A read-only validator can replace a value with an equal borrowed object. // A read-only validator can replace a value with an equal borrowed object.
// Detach without re-reading the environment or discarding validated state. // Detach without re-reading the environment or discarding validated state.
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets) configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
@@ -107,40 +179,23 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
accumulator.add(*issue) accumulator.add(*issue)
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
decoded, metadata := resolved.decoded, resolved.metadata decoded := resolved.decoded
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots metadata, final, preCLI := inspection.metadata, inspection.final, inspection.preCLI
structuralInput := document.Clone(resolved.structuralInput) finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
preCLI := document.Clone(resolved.preCLI) secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
allApplications := append(cloneHistoryApplications(applications), cloneHistoryApplications(resolved.postDecodeApps)...)
finalizeMetadataPaths(&metadata, final, allApplications, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, allApplications, resolved.versionEvidence, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false) sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false)
retainedTrees := make([]*document.Node, 0, len(allApplications)+5) retainedTrees := make([]*document.Node, 0, len(applications)+4)
retainedTrees = append(retainedTrees, decoded.final, final, structuralInput, preCLI) retainedTrees = append(retainedTrees, decoded.final, final, preCLI)
for _, application := range allApplications { for _, application := range applications {
retainedTrees = append(retainedTrees, application.value) retainedTrees = append(retainedTrees, application.value)
} }
inspection := &Inspection{ inspection.metadata = metadata
metadata: metadata, inspection.sources = sources
sources: sources, inspection.contexts = cloneResourceContexts(resolved.contexts)
contexts: cloneResourceContexts(resolved.contexts), inspection.diagnostics = canonicalDiagnostics(accumulator.diagnostics)
diagnostics: canonicalDiagnostics(accumulator.diagnostics), inspection.immutableLookup = buildDocumentLookup(retainedTrees...)
baseline: decoded.baseline, inspection.secrets = secrets
input: decoded.final, inspection.secretReferences = cloneSecretReferences(references)
inputOrigins: cloneOriginIndex(decoded.origins),
structuralInput: structuralInput,
preCLI: preCLI,
preCLIOrigins: cloneOrigins(resolved.preCLIOrigins),
postDecodeApps: cloneHistoryApplications(resolved.postDecodeApps),
final: final,
immutableLookup: buildDocumentLookup(retainedTrees...),
resolution: resolutionSnapshots,
resolutionReplay: coalescedResolutionSnapshots(resolutionSnapshots),
applications: applications,
inheritance: inheritanceOperations(resolved.inheritance),
secrets: secrets,
secretReferences: cloneSecretReferences(references),
}
return &Loaded[T]{Config: configuration, Inspection: inspection}, nil return &Loaded[T]{Config: configuration, Inspection: inspection}, nil
} }

View File

@@ -246,22 +246,36 @@ func TestNativeFilesystemDetectsRepeatedAndCyclicImports(t *testing.T) {
} }
type observedFilesystem struct { type observedFilesystem struct {
files fstest.MapFS files fstest.MapFS
opened int opened int
closed int closed int
fallback bool fallback bool
failRead string failOpen string
failStat string failRead string
failDir string failStat string
failDir string
failReadDir string
readDirErr error
failNoProgress string
failClose string
closeErr error
failFallbackDir string
fallbackErr error
fallbackBeforeClose bool
handles []*observedFile
} }
func (f *observedFilesystem) Open(name string) (fs.File, error) { func (f *observedFilesystem) Open(name string) (fs.File, error) {
if name == f.failOpen {
return nil, fs.ErrNotExist
}
inner, err := f.files.Open(name) inner, err := f.files.Open(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
f.opened++ f.opened++
file := &observedFile{File: inner, owner: f, name: name} file := &observedFile{File: inner, owner: f, name: name}
f.handles = append(f.handles, file)
if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback { if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback {
return &observedDirectoryFile{observedFile: file, directory: directory}, nil return &observedDirectoryFile{observedFile: file, directory: directory}, nil
} }
@@ -272,20 +286,36 @@ func (f *observedFilesystem) ReadDir(name string) ([]fs.DirEntry, error) {
if name == f.failDir { if name == f.failDir {
return nil, errors.New("tracking failure canary") return nil, errors.New("tracking failure canary")
} }
return fs.ReadDir(f.files, name) entries, err := fs.ReadDir(f.files, name)
if name == f.failFallbackDir {
for _, handle := range f.handles {
if handle.name == name && !handle.closed {
f.fallbackBeforeClose = true
}
}
return entries, f.fallbackErr
}
return entries, err
} }
func (f *observedFilesystem) assertClosed(t *testing.T) { func (f *observedFilesystem) assertClosed(t *testing.T) {
t.Helper() t.Helper()
if f.opened == 0 || f.opened != f.closed { if f.opened != f.closed || len(f.handles) != f.opened {
t.Fatalf("filesystem opens=%d closes=%d", f.opened, f.closed) t.Fatalf("filesystem opens=%d closes=%d handles=%d", f.opened, f.closed, len(f.handles))
}
for _, handle := range f.handles {
if !handle.closed || handle.closeCalls != 1 {
t.Errorf("filesystem handle %q closed=%t close calls=%d", handle.name, handle.closed, handle.closeCalls)
}
} }
} }
type observedFile struct { type observedFile struct {
fs.File fs.File
owner *observedFilesystem owner *observedFilesystem
name string name string
closed bool
closeCalls int
} }
func (f *observedFile) Read(data []byte) (int, error) { func (f *observedFile) Read(data []byte) (int, error) {
@@ -304,7 +334,16 @@ func (f *observedFile) Stat() (fs.FileInfo, error) {
func (f *observedFile) Close() error { func (f *observedFile) Close() error {
f.owner.closed++ f.owner.closed++
return f.File.Close() f.closeCalls++
f.closed = true
err := f.File.Close()
if f.name == f.owner.failClose {
if f.owner.closeErr != nil {
return f.owner.closeErr
}
return fs.ErrNotExist
}
return err
} }
type observedDirectoryFile struct { type observedDirectoryFile struct {
@@ -316,5 +355,15 @@ func (f *observedDirectoryFile) ReadDir(count int) ([]fs.DirEntry, error) {
if f.name == f.owner.failDir { if f.name == f.owner.failDir {
return nil, errors.New("tracking failure canary") return nil, errors.New("tracking failure canary")
} }
return f.directory.ReadDir(count) if f.name == f.owner.failNoProgress {
return nil, nil
}
entries, err := f.directory.ReadDir(count)
if f.name == f.owner.failReadDir {
if f.owner.readDirErr != nil {
return entries, f.owner.readDirErr
}
return entries, fs.ErrNotExist
}
return entries, err
} }

View File

@@ -2,7 +2,7 @@ package engine
func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagnostic) { func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagnostic) {
var diagnostics []Diagnostic var diagnostics []Diagnostic
if input.placementMode != placementModeMapping && input.placementMode != placementModeSequenceItem { if input.placementMode != PlacementModeMapping && input.placementMode != PlacementModeSequenceItem {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
} }
if normalized, err := normalizeDestination(input.destination); err != nil { if normalized, err := normalizeDestination(input.destination); err != nil {
@@ -10,7 +10,7 @@ func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagn
} else { } else {
input.destination = normalized input.destination = normalized
} }
if input.placementMode == placementModeSequenceItem && len(input.destination.segments) == 0 { if input.placementMode == PlacementModeSequenceItem && len(input.destination.segments) == 0 {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination"))
} }
if input.filesystem != nil { if input.filesystem != nil {
@@ -35,7 +35,7 @@ func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagn
return input, canonicalDiagnostics(diagnostics) return input, canonicalDiagnostics(diagnostics)
} }
func loadSettings[T any](defaults importDefaults, definition Definition[T], mode sourceVersionMode) (importDefaults, sequencePolicy, []Diagnostic) { func loadSettings[T any](defaults ImportDefaults, definition Definition[T], mode sourceVersionMode) (ImportDefaults, sequencePolicy, []Diagnostic) {
defaults = snapshotImportDefaults(defaults) defaults = snapshotImportDefaults(defaults)
var diagnostics []Diagnostic var diagnostics []Diagnostic
diagnostics = append(diagnostics, validateImportDefaults(defaults)...) diagnostics = append(diagnostics, validateImportDefaults(defaults)...)

View File

@@ -83,7 +83,6 @@ func (a *assemblyAccumulator) applyOverlayWithCollectionProjection(
if a == nil || a.current == nil || a.current.root == nil || a.current.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping { if a == nil || a.current == nil || a.current.root == nil || a.current.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
return nil, invalidOverlayDocument() return nil, invalidOverlayDocument()
} }
mergeOriginContribution(a.current.origins, Path{}, incomingOrigin(incomingOrigins, Path{}, overlay, layer))
sequences, diagnostics := mergeOverlayMappingsWithCollectionPolicy(a, overlay, incomingOrigins, incomingPolicies, layer, sequencePolicy, declarations) sequences, diagnostics := mergeOverlayMappingsWithCollectionPolicy(a, overlay, incomingOrigins, incomingPolicies, layer, sequencePolicy, declarations)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
@@ -110,11 +109,39 @@ func mergeOverlayMappingsWithCollectionPolicy(
sequencePolicy sequencePolicy, sequencePolicy sequencePolicy,
declarations []sequenceDeclarationSpec, declarations []sequenceDeclarationSpec,
) ([]sequenceHistoryContribution, []Diagnostic) { ) ([]sequenceHistoryContribution, []Diagnostic) {
result := assembly.current prepared, diagnostics := prepareOverlayCollection(
assembly.current.collectionPolicies, overlay, sequencePolicy, declarations,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
operation := newOverlayMergeOperation(assembly, overlay, incomingOrigins, incomingPolicies, layer, prepared)
operation.apply()
if len(operation.diagnostics) != 0 {
return nil, canonicalDiagnostics(operation.diagnostics)
}
return finalizeOverlayCollection(assembly, prepared, operation)
}
// preparedOverlayCollection contains decisions made against the read-only
// lower policy scope. Incoming declarations remain grouped by destination for
// declaration-only finalization after value application.
type preparedOverlayCollection struct {
declarationsByPath map[string][]sequenceDeclarationSpec
resolutions map[string]collectionPolicyResolution
sequencePolicy sequencePolicy
}
func prepareOverlayCollection(
lowerPolicies collectionPolicyScope,
overlay *document.Node,
sequencePolicy sequencePolicy,
declarations []sequenceDeclarationSpec,
) (*preparedOverlayCollection, []Diagnostic) {
claims := compositionClaims{byPath: make(map[string][]compositionClaim)} claims := compositionClaims{byPath: make(map[string][]compositionClaim)}
claims.append(overlay, Path{}, 0) claims.append(overlay, Path{}, 0)
identityFieldsByPath, resolvedPolicies, diagnostics := resolveStrictCollectionPolicies( identityFieldsByPath, resolvedPolicies, diagnostics := resolveStrictCollectionPolicies(
claims.ordered, sequencePolicy, result.collectionPolicies, declarations, claims.ordered, sequencePolicy, lowerPolicies, declarations,
) )
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
@@ -128,26 +155,21 @@ func mergeOverlayMappingsWithCollectionPolicy(
} }
registered := make(map[string]bool) registered := make(map[string]bool)
for _, claim := range claims.ordered { for _, claim := range claims.ordered {
key := claim.path.String()
if _, hasLowerPolicy := lowerPolicies.collections[key]; hasLowerPolicy {
registered[key] = true
registeredPaths[key] = claim.path.clone()
}
if claim.kind == document.Sequence { if claim.kind == document.Sequence {
if _, ok := sequencePolicy.identityFieldsAt(claim.path); ok { if _, ok := sequencePolicy.identityFieldsAt(claim.path); ok {
registered[claim.path.String()] = true registered[key] = true
registeredPaths[claim.path.String()] = claim.path.clone() registeredPaths[key] = claim.path.clone()
} }
} }
} }
for key := range declarationsByPath { for key := range declarationsByPath {
registered[key] = true registered[key] = true
} }
var overlayLookup *documentLookup
if len(result.collectionPolicies.collections) != 0 {
overlayLookup = buildDocumentLookup(overlay)
}
for key := range result.collectionPolicies.collections {
if node, found := documentAtPathWithLookup(overlay, result.collectionPolicies.collections[key].destination, overlayLookup); found && node != nil {
registered[key] = true
registeredPaths[key] = result.collectionPolicies.collections[key].destination.clone()
}
}
resolutions := make(map[string]collectionPolicyResolution, len(registered)) resolutions := make(map[string]collectionPolicyResolution, len(registered))
for key := range registered { for key := range registered {
@@ -167,65 +189,116 @@ func mergeOverlayMappingsWithCollectionPolicy(
effectiveIdentityFields: cloneStrings(identityFieldsByPath[key]), effectiveIdentityFields: cloneStrings(identityFieldsByPath[key]),
} }
} }
return &preparedOverlayCollection{
declarationsByPath: declarationsByPath,
resolutions: resolutions,
sequencePolicy: sequencePolicy,
}, nil
}
sequenceApplications := make(map[string]sequenceHistoryContribution) // overlayMergeOperation owns recursive value application and its associated
policyValues := make(map[string]bool) // origin, policy, diagnostic, and sequence-history evidence.
var combinationDiagnostics []Diagnostic type overlayMergeOperation struct {
mergeOverlayMappingsWithHooks(result.root, overlay, Path{}, &assembly.index, assembly *assemblyAccumulator
recursiveMergeHooks{ result *composedDocument
added: func(path Path, incoming *document.Node) { index *mappingIndex
collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer) overlay *document.Node
}, incomingOrigins map[string]Origin
combined: func(path Path, incoming *document.Node) { incomingPolicies collectionPolicyScope
mergeOriginContribution(result.origins, path, incomingOrigin(incomingOrigins, path, incoming, layer)) layer Layer
}, prepared *preparedOverlayCollection
replaced: func(path Path, existing, incoming *document.Node) { diagnostics []Diagnostic
assembly.current.collectionPolicies.retireCollectionPoliciesAtOrBelow(path) sequenceApplications map[string]sequenceHistoryContribution
recordOverlayReplacement(result, path, existing, incoming, layer, true, incomingOrigins) valueDestinations map[string]bool
}, }
merge: func(path Path, existing, incoming *document.Node) (*document.Node, bool) {
resolution, active := resolutions[path.String()] func newOverlayMergeOperation(
if !active { assembly *assemblyAccumulator,
return nil, false overlay *document.Node,
} incomingOrigins map[string]Origin,
combined, issues := combineSequence(existing, incoming, resolution, sequenceCombinationContext{destination: path}) incomingPolicies collectionPolicyScope,
if len(issues) != 0 { layer Layer,
combinationDiagnostics = append(combinationDiagnostics, issues...) prepared *preparedOverlayCollection,
return existing, false ) *overlayMergeOperation {
} return &overlayMergeOperation{
applyOverlaySequenceEvidence(result, path, existing, incoming, combined, resolution, incomingOrigins, incomingPolicies, layer) assembly: assembly, result: assembly.current, index: &assembly.index, overlay: overlay,
policyValues[path.String()] = true incomingOrigins: incomingOrigins, incomingPolicies: incomingPolicies,
history := sequenceHistoryContribution{path: path.clone(), mode: sequenceHistoryMapped} layer: layer, prepared: prepared,
if resolution.operation == sequenceModeReplace { sequenceApplications: make(map[string]sequenceHistoryContribution),
history.mode = sequenceHistoryReplace valueDestinations: make(map[string]bool),
if incoming != nil && incoming.Kind == document.Sequence {
history.length = len(incoming.Sequence)
}
} else {
history.items = append([]sequenceItemProjection(nil), combined.higherItems...)
history.replacements = append([]sequenceItemReplacement(nil), combined.replacements...)
}
sequenceApplications[path.String()] = history
return combined.value, true
},
},
)
if len(combinationDiagnostics) != 0 {
return nil, canonicalDiagnostics(combinationDiagnostics)
} }
}
// Values are now complete. Index lazily for declaration-only checks; func (operation *overlayMergeOperation) apply() {
// this lookup is never retained across the value-changing merge above. mergeOriginContribution(operation.result.origins, Path{}, incomingOrigin(operation.incomingOrigins, Path{}, operation.overlay, operation.layer))
mergeOverlayMappingsWithHooks(
operation.result.root, operation.overlay, Path{}, operation.index,
operation.callbacks(),
)
}
func (operation *overlayMergeOperation) callbacks() recursiveMergeHooks {
return recursiveMergeHooks{
added: operation.added,
combined: operation.combined,
replaced: operation.replaced,
merge: operation.merge,
}
}
func (operation *overlayMergeOperation) added(path Path, incoming *document.Node) {
collectOverlayOriginsFrom(operation.result.origins, operation.incomingOrigins, incoming, path, operation.layer)
}
func (operation *overlayMergeOperation) combined(path Path, incoming *document.Node) {
mergeOriginContribution(operation.result.origins, path, incomingOrigin(operation.incomingOrigins, path, incoming, operation.layer))
}
func (operation *overlayMergeOperation) replaced(path Path, existing, incoming *document.Node) {
operation.assembly.retireCollectionPoliciesAtOrBelow(path)
recordOverlayReplacement(operation.result, path, existing, incoming, operation.layer, true, operation.incomingOrigins)
}
func (operation *overlayMergeOperation) merge(path Path, existing, incoming *document.Node) (*document.Node, bool) {
resolution, active := operation.prepared.resolutions[path.String()]
if !active {
return nil, false
}
combined, issues := combineSequence(existing, incoming, resolution, sequenceCombinationContext{destination: path})
if len(issues) != 0 {
operation.diagnostics = append(operation.diagnostics, issues...)
return existing, false
}
applyOverlaySequenceEvidence(
operation.assembly, path, existing, incoming, combined, resolution,
operation.incomingOrigins, operation.incomingPolicies, operation.layer,
)
operation.valueDestinations[path.String()] = true
operation.sequenceApplications[path.String()] = sequenceHistoryContributionForCombination(
path, resolution.operation, incoming, combined,
)
return combined.value, true
}
func finalizeOverlayCollection(
assembly *assemblyAccumulator,
prepared *preparedOverlayCollection,
operation *overlayMergeOperation,
) ([]sequenceHistoryContribution, []Diagnostic) {
var diagnostics []Diagnostic
// Values are complete. Build the retained lookup lazily only if a
// declaration-only destination needs to be checked.
var retainedLookup *documentLookup var retainedLookup *documentLookup
for key, grouped := range declarationsByPath { for key, grouped := range prepared.declarationsByPath {
if policyValues[key] { if operation.valueDestinations[key] {
continue continue
} }
path := grouped[0].destination path := grouped[0].destination
applicationIdentity, _ := sequencePolicy.identityFieldsAt(path) applicationIdentity, _ := prepared.sequencePolicy.identityFieldsAt(path)
current, diagnostics := resolveCollectionPolicy(path, grouped, applicationIdentity, nil) current, issues := resolveCollectionPolicy(path, grouped, applicationIdentity, nil)
if len(diagnostics) != 0 { if len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, diagnostics...) diagnostics = append(diagnostics, issues...)
continue continue
} }
lower, found := assembly.current.collectionPolicies.at(path) lower, found := assembly.current.collectionPolicies.at(path)
@@ -239,28 +312,28 @@ func mergeOverlayMappingsWithCollectionPolicy(
retained, _ := documentAtPathWithLookup(assembly.current.root, path, retainedLookup) retained, _ := documentAtPathWithLookup(assembly.current.root, path, retainedLookup)
if retained != nil && retained.Kind == document.Sequence && len(current.effectiveIdentityFields) != 0 { if retained != nil && retained.Kind == document.Sequence && len(current.effectiveIdentityFields) != 0 {
if _, issues := validateSequenceIdentity(retained, path, current.effectiveIdentityFields, sequenceIdentityDiagnosticCodes{invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict}); len(issues) != 0 { if _, issues := validateSequenceIdentity(retained, path, current.effectiveIdentityFields, sequenceIdentityDiagnosticCodes{invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict}); len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...) diagnostics = append(diagnostics, issues...)
continue continue
} }
} }
policy, issues := collectionPolicyWithoutValue(lowerPolicy, current.policy, retained, path) policy, issues := collectionPolicyWithoutValue(lowerPolicy, current.policy, retained, path)
if len(issues) != 0 { if len(issues) != 0 {
combinationDiagnostics = append(combinationDiagnostics, issues...) diagnostics = append(diagnostics, issues...)
continue continue
} }
assembly.current.collectionPolicies.set(path, policy) assembly.setCollectionPolicy(path, policy)
} }
if len(combinationDiagnostics) != 0 { if len(diagnostics) != 0 {
return nil, canonicalDiagnostics(combinationDiagnostics) return nil, canonicalDiagnostics(diagnostics)
} }
keys := make([]string, 0, len(sequenceApplications)) keys := make([]string, 0, len(operation.sequenceApplications))
for key := range sequenceApplications { for key := range operation.sequenceApplications {
keys = append(keys, key) keys = append(keys, key)
} }
slices.Sort(keys) slices.Sort(keys)
sequences := make([]sequenceHistoryContribution, 0, len(keys)) sequences := make([]sequenceHistoryContribution, 0, len(keys))
for _, key := range keys { for _, key := range keys {
sequences = append(sequences, sequenceApplications[key]) sequences = append(sequences, operation.sequenceApplications[key])
} }
return sequences, nil return sequences, nil
} }
@@ -328,7 +401,7 @@ func recordOverlayReplacement(
} }
func applyOverlaySequenceEvidence( func applyOverlaySequenceEvidence(
result *composedDocument, assembly *assemblyAccumulator,
path Path, path Path,
lower, higher *document.Node, lower, higher *document.Node,
combined sequenceCombinationResult, combined sequenceCombinationResult,
@@ -337,19 +410,20 @@ func applyOverlaySequenceEvidence(
incomingPolicies collectionPolicyScope, incomingPolicies collectionPolicyScope,
layer Layer, layer Layer,
) { ) {
result := assembly.current
lowerPolicy, hadLowerPolicy := result.collectionPolicies.at(path) lowerPolicy, hadLowerPolicy := result.collectionPolicies.at(path)
if resolution.operation == sequenceModeReplace { if resolution.operation == sequenceModeReplace {
var lowerValue *collectionPolicy var lowerValue *collectionPolicy
if hadLowerPolicy { if hadLowerPolicy {
lowerValue = &lowerPolicy lowerValue = &lowerPolicy
} }
result.collectionPolicies.retireCollectionPoliciesAtOrBelow(path) assembly.retireCollectionPoliciesAtOrBelow(path)
resolution.policy = collectionPolicyForReplacement(lowerValue, resolution.policy) resolution.policy = collectionPolicyForReplacement(lowerValue, resolution.policy)
} }
if combined.value == nil || combined.value.Kind != document.Sequence { if combined.value == nil || combined.value.Kind != document.Sequence {
recordOverlayReplacement(result, path, lower, higher, layer, true, incomingOrigins) recordOverlayReplacement(result, path, lower, higher, layer, true, incomingOrigins)
result.collectionPolicies.set(path, resolution.policy) assembly.setCollectionPolicy(path, resolution.policy)
return return
} }
@@ -433,7 +507,7 @@ func applyOverlaySequenceEvidence(
policy.setRecordPolicyAt(outputIndex, replacedCollectionRecordPolicy()) policy.setRecordPolicyAt(outputIndex, replacedCollectionRecordPolicy())
} }
} }
result.collectionPolicies.set(path, policy) assembly.setCollectionPolicy(path, policy)
} }
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) { func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) {

View File

@@ -11,7 +11,6 @@ type postDecodeOverrideContext struct {
decoded decodedConfiguration decoded decodedConfiguration
metadata Metadata metadata Metadata
resolution resolutionInput resolution resolutionInput
sources []SourceInfo
applications []historyApplication applications []historyApplication
postDecodeApps []historyApplication postDecodeApps []historyApplication
referenceInput *document.Node referenceInput *document.Node
@@ -76,7 +75,6 @@ func preparePostDecodeOverrideContext(input assembledInput, before decodedConfig
decoded: after, decoded: after,
metadata: metadata, metadata: metadata,
resolution: resolution, resolution: resolution,
sources: sources,
applications: applications, applications: applications,
postDecodeApps: postDecodeApps, postDecodeApps: postDecodeApps,
referenceInput: documentWithMetadataLocations(assigned.final, metadata, input.document), referenceInput: documentWithMetadataLocations(assigned.final, metadata, input.document),

View File

@@ -50,7 +50,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
t.Fatalf("applyDecodedOverrides() issues = %#v", issues) t.Fatalf("applyDecodedOverrides() issues = %#v", issues)
} }
prepared := preparePostDecodeOverrideContext(input, decoded, assigned) prepared := preparePostDecodeOverrideContext(input, decoded, assigned)
if prepared.structuralInput == structural || prepared.preCLI == decoded.final || prepared.decoded.final == assigned.preCLI || if prepared.structuralInput == structural || prepared.preCLI == decoded.final ||
prepared.decoded.final == assigned.final || prepared.referenceInput == assigned.final { prepared.decoded.final == assigned.final || prepared.referenceInput == assigned.final {
t.Fatal("structural, pre-CLI, post-CLI, and located snapshots are not detached") t.Fatal("structural, pre-CLI, post-CLI, and located snapshots are not detached")
} }
@@ -72,9 +72,6 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
if len(prepared.applications) != 1 || len(prepared.postDecodeApps) != len(batch.overrides) || len(prepared.historyApplications()) != 1+len(batch.overrides) { if len(prepared.applications) != 1 || len(prepared.postDecodeApps) != len(batch.overrides) || len(prepared.historyApplications()) != 1+len(batch.overrides) {
t.Fatalf("history applications = structural:%d CLI:%d all:%d", len(prepared.applications), len(prepared.postDecodeApps), len(prepared.historyApplications())) t.Fatalf("history applications = structural:%d CLI:%d all:%d", len(prepared.applications), len(prepared.postDecodeApps), len(prepared.historyApplications()))
} }
if len(prepared.sources) != len(sources)+len(batch.overrides) || prepared.sources[len(prepared.sources)-1].Layer != LayerCLI {
t.Fatalf("prepared source order = %#v", prepared.sources)
}
if prepared.resolution.contexts["source-1"].BaseDir != "/config" { if prepared.resolution.contexts["source-1"].BaseDir != "/config" {
t.Fatalf("resolution source contexts = %#v", prepared.resolution.contexts) t.Fatalf("resolution source contexts = %#v", prepared.resolution.contexts)
} }
@@ -88,10 +85,10 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
inspection := Inspection{ inspection := Inspection{
metadata: prepared.metadata, baseline: decoded.baseline, input: assigned.final, inputOrigins: assigned.origins, metadata: prepared.metadata, baseline: decoded.baseline, input: assigned.final, inputOrigins: assigned.origins,
structuralInput: prepared.structuralInput, preCLI: prepared.preCLI, preCLIOrigins: prepared.preCLIOrigins, preCLI: prepared.preCLI, preCLIOrigins: prepared.preCLIOrigins,
postDecodeApps: prepared.postDecodeApps, final: assigned.final, postDecodeApps: prepared.postDecodeApps, final: assigned.final,
applications: prepared.applications, applications: prepared.applications,
immutableLookup: buildDocumentLookup(prepared.structuralInput, prepared.preCLI, assigned.final, immutableLookup: buildDocumentLookup(prepared.preCLI, assigned.final,
prepared.applications[0].value, prepared.postDecodeApps[0].value, prepared.postDecodeApps[1].value, prepared.applications[0].value, prepared.postDecodeApps[0].value, prepared.postDecodeApps[1].value,
prepared.postDecodeApps[2].value, prepared.postDecodeApps[3].value), prepared.postDecodeApps[2].value, prepared.postDecodeApps[3].value),
} }

View File

@@ -10,7 +10,6 @@ import (
// contributions alongside the effective typed value and its inspection data. // contributions alongside the effective typed value and its inspection data.
type decodedOverrideResult struct { type decodedOverrideResult struct {
value reflect.Value value reflect.Value
preCLI *document.Node
final *document.Node final *document.Node
presence map[string]inputPresence presence map[string]inputPresence
origins map[string]Origin origins map[string]Origin
@@ -30,7 +29,6 @@ func applyDecodedOverrides(
return decodedOverrideResult{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "decoded configuration does not match the schema"}} return decodedOverrideResult{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "decoded configuration does not match the schema"}}
} }
preCLI := document.Clone(decoded.final)
working, cloneIssue := cloneConfiguration(schema, decoded.value, defaultProjectionLimits()) working, cloneIssue := cloneConfiguration(schema, decoded.value, defaultProjectionLimits())
if cloneIssue != nil { if cloneIssue != nil {
return decodedOverrideResult{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)} return decodedOverrideResult{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)}
@@ -76,7 +74,6 @@ func applyDecodedOverrides(
return decodedOverrideResult{ return decodedOverrideResult{
value: working, value: working,
preCLI: preCLI,
final: final, final: final,
presence: presence, presence: presence,
origins: origins, origins: origins,

View File

@@ -622,8 +622,8 @@ func TestApplyDecodedOverridesUsesSchemaAndPreservesTypedOwnership(t *testing.T)
got.Text != "cli text" || got.Delay != 250*time.Millisecond || got.APIKey.name != "CLI_SECRET" || got.Maybe != nil { got.Text != "cli text" || got.Delay != 250*time.Millisecond || got.APIKey.name != "CLI_SECRET" || got.Maybe != nil {
t.Fatalf("post-decode configuration = %#v", got) t.Fatalf("post-decode configuration = %#v", got)
} }
if result.preCLI == result.final || !reflect.DeepEqual(result.preCLI, decoded.final) || len(result.overrides) != len(batch.overrides) { if len(result.overrides) != len(batch.overrides) {
t.Fatalf("result snapshots/contributions = pre:%p final:%p overrides:%d", result.preCLI, result.final, len(result.overrides)) t.Fatalf("result override contributions = %d, want %d", len(result.overrides), len(batch.overrides))
} }
if _, explicit := result.presence["/items/0/enabled"]; explicit { if _, explicit := result.presence["/items/0/enabled"]; explicit {
t.Fatalf("replacement list inherited presence from its discarded item: %#v", result.presence) t.Fatalf("replacement list inherited presence from its discarded item: %#v", result.presence)

View File

@@ -0,0 +1,112 @@
package engine
import (
"context"
"errors"
"os"
"os/exec"
"reflect"
"testing"
"time"
)
type pointerOnlyLoop *pointerOnlyLoop
type pointerOnlyConfig struct {
Loop pointerOnlyLoop `yaml:"loop"`
}
func TestPointerOnlyDescriptorsTerminate(t *testing.T) {
if mode := os.Getenv("CONFIGKIT_POINTER_DESCRIPTOR_TEST_MODE"); mode != "" {
switch mode {
case "decode":
_, err := testLoad[pointerOnlyConfig](Bytes("config.yml", []byte("loop: {}\n")))
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("load error = %v, want a safe decode failure", err)
}
for _, diagnostic := range loadErr.Diagnostics() {
if diagnostic.Code == CodeDecodeType && diagnostic.Path.String() == "/loop" {
return
}
}
t.Fatalf("diagnostics = %#v, want decode type at /loop", loadErr.Diagnostics())
case "paths":
schema, issue := testBuildSchema(reflect.TypeFor[pointerOnlyConfig]())
if issue != nil {
t.Fatalf("schema issue = %#v", issue)
}
if _, ok := resolveSchemaPath(schema, Keys("loop", "child")); ok {
t.Fatal("descendant below a pointer-only loop was accepted")
}
if _, ok := schemaSecretRoot(schema, Keys("loop", "child")); ok {
t.Fatal("secret path below a pointer-only loop was accepted")
}
if acceptsMapping(schema.root.fields[schema.root.fieldByName["loop"]].descriptor) {
t.Fatal("pointer-only loop was accepted as a mapping")
}
default:
t.Fatalf("unknown test mode %q", mode)
}
return
}
for _, mode := range []string{"decode", "paths"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPointerOnlyDescriptorsTerminate$")
command.Env = append(os.Environ(), "CONFIGKIT_POINTER_DESCRIPTOR_TEST_MODE="+mode)
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("pointer descriptor %s did not terminate: %s", mode, output)
}
if err != nil {
t.Fatalf("pointer descriptor %s failed: %v, %s", mode, err, output)
}
})
}
}
func TestPointerOnlyNullableFieldAndRecursiveValues(t *testing.T) {
for name, source := range map[string]Source{
"omitted": NoSource(),
"null": Bytes("config.yml", []byte("loop: null\n")),
} {
t.Run(name, func(t *testing.T) {
result, err := testLoad[pointerOnlyConfig](source)
if err != nil || result.Config.Loop != nil {
t.Fatalf("result = %#v, error = %v", result, err)
}
})
}
result, err := testLoad[recursiveConfig](Bytes("config.yml", []byte("name: root\nnext:\n name: child\n next:\n name: leaf\n")))
if err != nil {
t.Fatal(err)
}
if result.Config.Name != "root" || result.Config.Next == nil || result.Config.Next.Name != "child" || result.Config.Next.Next == nil || result.Config.Next.Next.Name != "leaf" {
t.Fatalf("recursive value = %#v", result.Config)
}
schema, issue := testBuildSchema(reflect.TypeFor[pointerOnlyConfig]())
if issue != nil {
t.Fatal(issue.message)
}
resolved, ok := resolveSchemaPath(schema, Keys("loop"))
if !ok || resolved.descriptor.kind != descriptorPointer {
t.Fatalf("nullable pointer field resolution = %#v, %v", resolved, ok)
}
called := false
nullableResult, err := testLoad[pointerOnlyConfig](NoSource(), Options[pointerOnlyConfig]{Definition: Definition[pointerOnlyConfig]{
Resolve: func(config *pointerOnlyConfig, resolution *Resolution) error {
called = true
Normalize(resolution, Keys("loop"), &config.Loop, nil, "clear nullable field")
return nil
},
}})
if err != nil || !called || nullableResult.Config.Loop != nil {
t.Fatalf("nullable field operation = %#v, called=%v, error=%v", nullableResult, called, err)
}
}

View File

@@ -137,7 +137,9 @@ func (o *valueOperation) projectStruct(value reflect.Value, descriptor *typeDesc
if isNilValue(memberValue) { if isNilValue(memberValue) {
continue continue
} }
o.nodeCredits++
inline, issue := o.project(memberValue, member.descriptor, path, depth) inline, issue := o.project(memberValue, member.descriptor, path, depth)
o.nodeCredits--
if issue != nil { if issue != nil {
return nil, issue return nil, issue
} }
@@ -193,6 +195,9 @@ func (o *valueOperation) projectMap(value reflect.Value, descriptor *typeDescrip
if issue != nil { if issue != nil {
return nil, issue return nil, issue
} }
if issue := o.checkMinimumChildren(path, value.Len(), 2); issue != nil {
return nil, issue
}
result.Mapping = make([]document.Entry, 0, value.Len()) result.Mapping = make([]document.Entry, 0, value.Len())
keys := value.MapKeys() keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { slices.SortFunc(keys, func(left, right reflect.Value) int {
@@ -217,6 +222,9 @@ func (o *valueOperation) projectSequence(value reflect.Value, descriptor *typeDe
if issue != nil { if issue != nil {
return nil, issue return nil, issue
} }
if issue := o.checkMinimumChildren(path, value.Len(), 1); issue != nil {
return nil, issue
}
result.Sequence = make([]*document.Node, 0, value.Len()) result.Sequence = make([]*document.Node, 0, value.Len())
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
childPath := path.Index(index) childPath := path.Index(index)

View File

@@ -0,0 +1,101 @@
package engine
import (
"context"
"os"
"os/exec"
"reflect"
"testing"
"time"
)
type sliceBudgetConfig struct {
Values []string `yaml:"values"`
}
type arrayBudgetConfig struct {
Values [1]string `yaml:"values"`
}
type mapBudgetConfig struct {
Values map[string]string `yaml:"values"`
}
type inlineBudgetFields struct {
Name string `yaml:"name"`
}
type inlineStructBudgetConfig struct {
Fields inlineBudgetFields `yaml:",inline"`
}
type inlineMapBudgetConfig struct {
Fields map[string]string `yaml:",inline"`
}
type hugeZeroBudgetConfig struct {
Values []struct{} `yaml:"values"`
}
func TestProjectionChecksMinimumContainerNodes(t *testing.T) {
tests := []struct {
name string
typeOf reflect.Type
value any
exact int
}{
{name: "slice", typeOf: reflect.TypeFor[sliceBudgetConfig](), value: sliceBudgetConfig{Values: []string{"x"}}, exact: 4},
{name: "array", typeOf: reflect.TypeFor[arrayBudgetConfig](), value: arrayBudgetConfig{Values: [1]string{"x"}}, exact: 4},
{name: "map", typeOf: reflect.TypeFor[mapBudgetConfig](), value: mapBudgetConfig{Values: map[string]string{"x": "y"}}, exact: 5},
{name: "inline struct", typeOf: reflect.TypeFor[inlineStructBudgetConfig](), value: inlineStructBudgetConfig{Fields: inlineBudgetFields{Name: "x"}}, exact: 3},
{name: "inline map", typeOf: reflect.TypeFor[inlineMapBudgetConfig](), value: inlineMapBudgetConfig{Fields: map[string]string{"x": "y"}}, exact: 3},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
schema, issue := buildSchema(test.typeOf, testSecretBinding())
if issue != nil {
t.Fatal(issue.message)
}
for _, limits := range []struct {
name string
nodes int
fail bool
}{{name: "exact", nodes: test.exact}, {name: "insufficient", nodes: test.exact - 1, fail: true}} {
t.Run(limits.name, func(t *testing.T) {
_, projectionIssue := projectConfiguration(schema, reflect.ValueOf(test.value), projectionLimits{depth: maxDocumentDepth, nodes: limits.nodes})
if limits.fail {
if projectionIssue == nil || projectionIssue.code != CodeSchemaProjectionLimit {
t.Fatalf("projection issue = %#v, want limit failure", projectionIssue)
}
} else if projectionIssue != nil {
t.Fatalf("exact-budget projection issue = %#v", projectionIssue)
}
})
}
})
}
}
func TestProjectionRejectsMaximumZeroSizedSliceBeforeAllocation(t *testing.T) {
if os.Getenv("CONFIGKIT_HUGE_ZERO_SLICE_TEST") != "" {
schema := mustSchemaFor[hugeZeroBudgetConfig](t)
value := hugeZeroBudgetConfig{Values: make([]struct{}, int(^uint(0)>>1))}
_, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("projection issue = %#v, want a safe limit failure", issue)
}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestProjectionRejectsMaximumZeroSizedSliceBeforeAllocation$")
command.Env = append(os.Environ(), "CONFIGKIT_HUGE_ZERO_SLICE_TEST=1")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("maximum-length zero-sized slice projection did not terminate: %s", output)
}
if err != nil {
t.Fatalf("maximum-length zero-sized slice projection failed: %v, %s", err, output)
}
}

View File

@@ -196,7 +196,7 @@ type SourceInfo struct {
VersionCheck *SourceVersionCheck VersionCheck *SourceVersionCheck
importSelections []importSelectionEvidence importSelections []importSelectionEvidence
namespace *namespaceToken namespace *namespaceToken
placementMode placementMode placementMode PlacementMode
destinationUnresolved bool destinationUnresolved bool
} }

View File

@@ -287,8 +287,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
records: make(map[string]*resolutionRecord), records: make(map[string]*resolutionRecord),
} }
context := &Resolution{state: state} context := &Resolution{state: state}
err := resolver(configuration, context) var err error
state.active = false func() {
defer func() { state.active = false }()
err = resolver(configuration, context)
}()
if err != nil && !hasErrors(state.diagnostics) { if err != nil && !hasErrors(state.diagnostics) {
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed") state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
} }

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
) )
@@ -157,7 +158,7 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
child: "configkit: {imports: [{path: helper.yml}]}\nid: child\nvalue: child\n", child: "configkit: {imports: [{path: helper.yml}]}\nid: child\nvalue: child\n",
helper: "value: helper\n", helper: "value: helper\n",
options: Options[config]{ options: Options[config]{
Imports: true, destination: Keys("groups"), placementMode: placementModeSequenceItem, Imports: true, Destination: Keys("groups"), PlacementMode: PlacementModeSequenceItem,
}, },
wantErr: true, wantErr: true,
}, },
@@ -181,3 +182,191 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
}) })
} }
} }
func TestDirectorySelectorsFailOnPostOpenNotExistErrors(t *testing.T) {
const canary = "DIRECTORY_FAILURE_CANARY"
readFailure := errors.Join(fs.ErrNotExist, errors.New(canary))
closeFailure := errors.Join(fs.ErrNotExist, errors.New(canary))
for _, test := range []struct {
name string
selector string
fallback bool
readError bool
noProgress bool
closeError bool
fallbackErr bool
}{
{name: "directory batched read with entries", selector: "directory", readError: true},
{name: "directory close", selector: "directory", closeError: true},
{name: "directory fallback with entries", selector: "directory", fallback: true, fallbackErr: true},
{name: "directory no progress", selector: "directory", noProgress: true},
{name: "glob batched read with entries", selector: "glob", readError: true},
{name: "glob close", selector: "glob", closeError: true},
{name: "glob fallback with entries", selector: "glob", fallback: true, fallbackErr: true},
{name: "glob no progress", selector: "glob", noProgress: true},
} {
t.Run(test.name, func(t *testing.T) {
main := "configkit:\n imports:\n"
if test.selector == "directory" {
main += " - dir: [broken, good]\n pattern: '*.yml'\n"
} else {
main += " - glob: 'broken/*.yml'\n - glob: 'good/*.yml'\n"
}
filesystem := &observedFilesystem{
files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte(main)},
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"broken/bad.yml": &fstest.MapFile{Data: []byte("value: bad\n")},
"good": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good/good.yml": &fstest.MapFile{Data: []byte("value: good\n")},
},
fallback: test.fallback,
failReadDir: "broken",
readDirErr: readFailure,
failNoProgress: "broken",
failClose: "broken",
closeErr: closeFailure,
failFallbackDir: "broken",
fallbackErr: readFailure,
}
if !test.readError {
filesystem.failReadDir = ""
}
if !test.noProgress {
filesystem.failNoProgress = ""
}
if !test.closeError {
filesystem.failClose = ""
}
if !test.fallbackErr {
filesystem.failFallbackDir = ""
}
result, err := testLoad[map[string]any](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[map[string]any]{Imports: true})
if result != nil {
t.Fatalf("load returned a result after directory failure: %#v", result)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %#v, want structured LoadError", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one directory failure", diagnostics)
}
diagnostic := diagnostics[0]
wantCode := CodeImportInvalid
if test.selector == "glob" {
wantCode = CodeImportRead
}
if diagnostic.Code != wantCode || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" {
t.Fatalf("directory diagnostic = %#v, want %q at source-1", diagnostic, wantCode)
}
var rendered strings.Builder
if err := WriteDiagnostics(&rendered, diagnostics); err != nil {
t.Fatal(err)
}
if strings.Contains(err.Error()+diagnostic.Message+rendered.String(), canary) {
t.Fatalf("backing error canary escaped: error=%q diagnostic=%#v rendered=%q", err, diagnostic, rendered.String())
}
if filesystem.fallbackBeforeClose {
t.Fatal("fallback directory enumeration began before the opened handle was closed")
}
filesystem.assertClosed(t)
})
}
}
func TestReadDirectoryPreservesReadErrorOverCloseError(t *testing.T) {
readFailure := errors.New("read failure")
closeFailure := errors.New("close failure")
filesystem := &observedFilesystem{
files: fstest.MapFS{
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"broken/entry": &fstest.MapFile{Data: []byte("entry")},
},
failReadDir: "broken",
readDirErr: readFailure,
failClose: "broken",
closeErr: closeFailure,
}
access := newNativeAccess(filesystem, ".", &namespaceToken{})
visited := 0
err := access.readDirectory("broken", func(fs.DirEntry) bool {
visited++
return true
})
if !errors.Is(err, readFailure) || errors.Is(err, closeFailure) || visited != 1 {
t.Fatalf("directory read = %v, visited %d; want entries plus primary read failure", err, visited)
}
if missingDirectoryOpen(err) {
t.Fatal("post-open read failure was classified as missing-on-open")
}
filesystem.assertClosed(t)
}
func TestReadDirectoryVisitStopCloseNotExistIsPostOpen(t *testing.T) {
filesystem := &observedFilesystem{
files: fstest.MapFS{
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"broken/entry": &fstest.MapFile{Data: []byte("entry")},
},
failClose: "broken",
}
access := newNativeAccess(filesystem, ".", &namespaceToken{})
err := access.readDirectory("broken", func(fs.DirEntry) bool { return false })
if !errors.Is(err, fs.ErrNotExist) || missingDirectoryOpen(err) {
t.Fatalf("visit-stop close error = %v, missing-on-open=%t", err, missingDirectoryOpen(err))
}
filesystem.assertClosed(t)
}
func TestDirectorySelectorsKeepMissingRootsOptional(t *testing.T) {
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
main := "configkit:\n imports:\n"
if selector == "directory" {
main += " - dir: [missing, good]\n pattern: '*.yml'\n required: true\n"
} else {
main += " - glob: 'missing/*.yml'\n - glob: 'good/*.yml'\n required: true\n"
}
filesystem := &observedFilesystem{files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte(main)},
"good": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good/good.yml": &fstest.MapFile{Data: []byte("value: good\n")},
}}
result, err := testLoad[map[string]any](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[map[string]any]{Imports: true})
if err != nil || result == nil {
t.Fatalf("load with missing search root = %#v, %v", result, err)
}
filesystem.assertClosed(t)
})
}
}
func TestDirectorySelectorsTreatMissingOnOpenAsOptional(t *testing.T) {
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
main := "configkit:\n imports:\n"
if selector == "directory" {
main += " - dir: [broken, good]\n pattern: '*.yml'\n required: true\n"
} else {
main += " - glob: 'broken/*.yml'\n - glob: 'good/*.yml'\n required: true\n"
}
filesystem := &observedFilesystem{
files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte(main)},
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good/good.yml": &fstest.MapFile{Data: []byte("value: good\n")},
},
failOpen: "broken",
}
result, err := testLoad[map[string]any](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[map[string]any]{Imports: true})
if err != nil || result == nil {
t.Fatalf("load after missing-on-open root = %#v, %v", result, err)
}
filesystem.assertClosed(t)
})
}
}

View File

@@ -113,6 +113,7 @@ func buildSchema(root reflect.Type, secrets SecretBinding) (*schemaDescriptor, *
type schemaBuilder struct { type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool building map[reflect.Type]bool
journal []reflect.Type
secrets SecretBinding secrets SecretBinding
} }
@@ -122,6 +123,7 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
} }
descriptor := &typeDescriptor{typeOf: current} descriptor := &typeDescriptor{typeOf: current}
b.cache[current] = descriptor b.cache[current] = descriptor
b.journal = append(b.journal, current)
b.building[current] = true b.building[current] = true
defer delete(b.building, current) defer delete(b.building, current)
@@ -210,6 +212,13 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
return descriptor, nil return descriptor, nil
} }
func (b *schemaBuilder) rollback() {
for _, current := range b.journal {
delete(b.cache, current)
}
b.journal = nil
}
func (binding SecretBinding) validate() { func (binding SecretBinding) validate() {
if binding.Type == nil { if binding.Type == nil {
return return
@@ -334,8 +343,21 @@ func prependIndex(index int, nested []int) []int {
type schemaPath struct { type schemaPath struct {
descriptor *typeDescriptor descriptor *typeDescriptor
field *fieldDescriptor }
dynamic bool
// terminalDescriptor follows a pointer-only descriptor chain without entering
// any value. Recursive pointer aliases can refer back to an earlier descriptor,
// so callers must handle a repeated descriptor as an unaddressable chain.
func terminalDescriptor(descriptor *typeDescriptor) (*typeDescriptor, bool) {
visited := make(map[*typeDescriptor]struct{})
for descriptor != nil && descriptor.kind == descriptorPointer {
if _, exists := visited[descriptor]; exists {
return nil, false
}
visited[descriptor] = struct{}{}
descriptor = descriptor.element
}
return descriptor, descriptor != nil
} }
func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) { func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
@@ -343,10 +365,11 @@ func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
return schemaPath{}, false return schemaPath{}, false
} }
current := schema.root current := schema.root
var field *fieldDescriptor
for _, segment := range path.segments { for _, segment := range path.segments {
for current.kind == descriptorPointer { var ok bool
current = current.element current, ok = terminalDescriptor(current)
if !ok {
return schemaPath{}, false
} }
switch current.kind { switch current.kind {
case descriptorStruct: case descriptorStruct:
@@ -355,12 +378,10 @@ func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
} }
position, ok := current.fieldByName[segment.key] position, ok := current.fieldByName[segment.key]
if ok { if ok {
field = &current.fields[position] current = current.fields[position].descriptor
current = field.descriptor
continue continue
} }
if current.inlineMap != nil { if current.inlineMap != nil {
field = current.inlineMap
current = current.inlineMap.descriptor.element current = current.inlineMap.descriptor.element
continue continue
} }
@@ -369,26 +390,20 @@ func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
if segment.kind == pathSegmentIndex { if segment.kind == pathSegmentIndex {
return schemaPath{}, false return schemaPath{}, false
} }
field = nil
current = current.element current = current.element
case descriptorSlice, descriptorArray: case descriptorSlice, descriptorArray:
index, ok := schemaIndex(segment) index, ok := schemaIndex(segment)
if !ok || (current.kind == descriptorArray && index >= current.length) { if !ok || (current.kind == descriptorArray && index >= current.length) {
return schemaPath{}, false return schemaPath{}, false
} }
field = nil
current = current.element current = current.element
case descriptorDynamic: case descriptorDynamic:
return schemaPath{descriptor: current, field: field, dynamic: true}, true return schemaPath{descriptor: current}, true
default: default:
return schemaPath{}, false return schemaPath{}, false
} }
} }
dynamic := current return schemaPath{descriptor: current}, true
for dynamic.kind == descriptorPointer {
dynamic = dynamic.element
}
return schemaPath{descriptor: current, field: field, dynamic: dynamic.kind == descriptorDynamic}, true
} }
func schemaIndex(segment pathSegment) (int, bool) { func schemaIndex(segment pathSegment) (int, bool) {

View File

@@ -134,12 +134,6 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
return closeSecretDependencies(paths, secret, dependencies, inherited) return closeSecretDependencies(paths, secret, dependencies, inherited)
} }
func addSecretEvidencePath(paths map[string]Path, destination, local Path) Path {
path := appendDestination(destination, local)
addSecretPath(paths, path.String())
return path
}
func addSecretPath(paths map[string]Path, pointer string) { func addSecretPath(paths map[string]Path, pointer string) {
if existing, found := paths[pointer]; found && existing.valid() { if existing, found := paths[pointer]; found && existing.valid() {
// Accumulator keys already match their paths' canonical spelling. History // Accumulator keys already match their paths' canonical spelling. History
@@ -168,8 +162,10 @@ func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
walked := Path{} walked := Path{}
for _, segment := range path.segments { for _, segment := range path.segments {
walked.segments = append(walked.segments, segment) walked.segments = append(walked.segments, segment)
for current.kind == descriptorPointer { var ok bool
current = current.element current, ok = terminalDescriptor(current)
if !ok {
return Path{}, false
} }
switch current.kind { switch current.kind {
case descriptorStruct: case descriptorStruct:

View File

@@ -21,6 +21,22 @@ type secretResolutionResult struct {
diagnostics []Diagnostic diagnostics []Diagnostic
} }
type secretReferenceLocationResolver struct {
input *document.Node
lookup *documentLookup
}
func (resolver *secretReferenceLocationResolver) diagnostic(code Code, path Path, message string) Diagnostic {
if resolver.lookup == nil {
resolver.lookup = buildDocumentLookup(resolver.input)
}
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocationWithLookup(resolver.input, path, resolver.lookup); ok {
diagnostic.Primary = documentLocationPointer(location)
}
return diagnostic
}
func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult { func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult {
result := secretResolutionResult{ result := secretResolutionResult{
lookups: make(map[string]secretLookupResult), references: make(map[string]string), lookups: make(map[string]secretLookupResult), references: make(map[string]string),
@@ -32,17 +48,18 @@ func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input
if lookup == nil { if lookup == nil {
lookup = func(string) (string, bool) { return "", false } lookup = func(string) (string, bool) { return "", false }
} }
resolveSecretValue(root, schema.root, schema.secrets, Path{}, false, input, lookup, &result) locations := &secretReferenceLocationResolver{input: input}
resolveSecretValue(root, schema.root, schema.secrets, Path{}, false, locations, lookup, &result)
result.diagnostics = canonicalDiagnostics(result.diagnostics) result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result return result
} }
func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) { func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets SecretBinding, path Path, optional bool, locations *secretReferenceLocationResolver, lookup secretLookup, result *secretResolutionResult) {
if descriptor.kind == descriptorPointer { if descriptor.kind == descriptorPointer {
if value.IsNil() { if value.IsNil() {
return return
} }
resolveSecretValue(value.Elem(), descriptor.element, secrets, path, descriptor.element.kind == descriptorSecret, input, lookup, result) resolveSecretValue(value.Elem(), descriptor.element, secrets, path, descriptor.element.kind == descriptorSecret, locations, lookup, result)
return return
} }
@@ -57,11 +74,11 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets
code = CodeSecretInvalidName code = CodeSecretInvalidName
message = "secret environment name is empty" message = "secret environment name is empty"
} }
result.diagnostics = append(result.diagnostics, secretDiagnostic(code, path, message, input)) result.diagnostics = append(result.diagnostics, locations.diagnostic(code, path, message))
return return
} }
if !validEnvironmentName(name) { if !validEnvironmentName(name) {
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretInvalidName, path, "secret environment name is invalid", input)) result.diagnostics = append(result.diagnostics, locations.diagnostic(CodeSecretInvalidName, path, "secret environment name is invalid"))
return return
} }
found, exists := result.lookups[name] found, exists := result.lookups[name]
@@ -70,11 +87,11 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets
result.lookups[name] = found result.lookups[name] = found
} }
if !found.present { if !found.present {
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretUnset, path, "secret environment variable is not set", input)) result.diagnostics = append(result.diagnostics, locations.diagnostic(CodeSecretUnset, path, "secret environment variable is not set"))
return return
} }
if found.value == "" { if found.value == "" {
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input)) result.diagnostics = append(result.diagnostics, locations.diagnostic(CodeSecretEmpty, path, "secret environment variable is empty"))
return return
} }
value.Set(secrets.Make(SecretState{Name: name, Value: found.value, Specified: true, Resolved: true})) value.Set(secrets.Make(SecretState{Name: name, Value: found.value, Specified: true, Resolved: true}))
@@ -85,7 +102,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets
if !member.inline { if !member.inline {
memberPath = path.Key(member.name) memberPath = path.Key(member.name)
} }
resolveSecretValue(value.Field(member.index), member.descriptor, secrets, memberPath, false, input, lookup, result) resolveSecretValue(value.Field(member.index), member.descriptor, secrets, memberPath, false, locations, lookup, result)
} }
case descriptorMap: case descriptorMap:
if value.IsNil() { if value.IsNil() {
@@ -96,7 +113,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets
for _, key := range keys { for _, key := range keys {
entry := reflect.New(value.Type().Elem()).Elem() entry := reflect.New(value.Type().Elem()).Elem()
entry.Set(value.MapIndex(key)) entry.Set(value.MapIndex(key))
resolveSecretValue(entry, descriptor.element, secrets, path.Key(key.String()), false, input, lookup, result) resolveSecretValue(entry, descriptor.element, secrets, path.Key(key.String()), false, locations, lookup, result)
value.SetMapIndex(key, entry) value.SetMapIndex(key, entry)
} }
case descriptorSlice, descriptorArray: case descriptorSlice, descriptorArray:
@@ -104,19 +121,11 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, secrets
return return
} }
for index := 0; index < value.Len(); index++ { for index := 0; index < value.Len(); index++ {
resolveSecretValue(value.Index(index), descriptor.element, secrets, path.Index(index), false, input, lookup, result) resolveSecretValue(value.Index(index), descriptor.element, secrets, path.Index(index), false, locations, lookup, result)
} }
} }
} }
func secretDiagnostic(code Code, path Path, message string, input *document.Node) Diagnostic {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(input, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
return diagnostic
}
func changedSecretStates(root reflect.Value, schema *schemaDescriptor, lookups map[string]secretLookupResult) []Path { func changedSecretStates(root reflect.Value, schema *schemaDescriptor, lookups map[string]secretLookupResult) []Path {
var changed []Path var changed []Path
if schema == nil || schema.root == nil { if schema == nil || schema.root == nil {

View File

@@ -131,6 +131,94 @@ func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
}) })
} }
func TestSecretFailureLocationsIndexWideInputOnce(t *testing.T) {
type config struct {
Secrets map[string]Secret `yaml:"secrets"`
DefaultOnly Secret `yaml:"defaultOnly"`
Sequence []Secret `yaml:"sequence"`
}
keys := []string{"a/b~c", "12", "ordinary"}
for index := 0; index < 128; index++ {
keys = append(keys, fmt.Sprintf("wide-%03d", index))
}
var source strings.Builder
source.WriteString("secrets:\n")
lookupCounts := make(map[string]int)
wantCodes := make(map[string]Code, len(keys)+3)
wantLines := make(map[string]int, len(keys)+2)
for index, key := range keys {
var name string
var code Code
switch index % 4 {
case 0:
name, code = "MISSING_SHARED", CodeSecretUnset
case 1:
name, code = "EMPTY_SHARED", CodeSecretEmpty
case 2:
name, code = "INVALID-NAME", CodeSecretInvalidName
default:
name, code = "", CodeSecretInvalidName
}
line := index + 2
fmt.Fprintf(&source, " %q: %q\n", key, name)
path := Path{}.Key("secrets").Key(key).String()
wantCodes[path] = code
wantLines[path] = line
}
sequenceHeaderLine := len(keys) + 2
source.WriteString("sequence:\n - MISSING_SHARED\n - EMPTY_SHARED\n")
sequencePaths := []string{Path{}.Key("sequence").Index(0).String(), Path{}.Key("sequence").Index(1).String()}
wantCodes[sequencePaths[0]] = CodeSecretUnset
wantCodes[sequencePaths[1]] = CodeSecretEmpty
wantLines[sequencePaths[0]] = sequenceHeaderLine + 1
wantLines[sequencePaths[1]] = sequenceHeaderLine + 2
defaultPath := Path{}.Key("defaultOnly").String()
wantCodes[defaultPath] = CodeSecretUnset
_, err := testLoadWithSecretLookup(Bytes("secret-index.yml", []byte(source.String())), func(name string) (string, bool) {
lookupCounts[name]++
if name == "EMPTY_SHARED" {
return "", true
}
return "", false
}, Options[config]{Definition: Definition[config]{Defaults: func() config {
return config{DefaultOnly: SecretEnv("MISSING_SHARED")}
}}})
loadError := requireSecretLoadCode(t, err, CodeSecretUnset)
diagnostics := loadError.Diagnostics()
if len(diagnostics) != len(wantCodes) {
t.Fatalf("diagnostic count = %d, want %d: %#v", len(diagnostics), len(wantCodes), diagnostics)
}
gotPaths := make(map[string]struct{}, len(diagnostics))
for _, diagnostic := range diagnostics {
path := diagnostic.Path.String()
gotPaths[path] = struct{}{}
if code, ok := wantCodes[path]; !ok || diagnostic.Code != code {
t.Errorf("diagnostic %q code = %q, want %q", path, diagnostic.Code, code)
}
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" {
t.Errorf("diagnostic %q location = %#v, want source source-1", path, diagnostic.Primary)
continue
}
if line, hasExactLine := wantLines[path]; hasExactLine && diagnostic.Primary.Line != line {
t.Errorf("diagnostic %q line = %d, want %d", path, diagnostic.Primary.Line, line)
}
if path == defaultPath && diagnostic.Primary.Line != 1 {
t.Errorf("defaults-only diagnostic line = %d, want nearest root line 1", diagnostic.Primary.Line)
}
}
if _, ok := gotPaths["/secrets/a~1b~0c"]; !ok {
t.Fatal("escaped mapping key diagnostic path was not preserved")
}
if _, ok := gotPaths["/secrets/12"]; !ok {
t.Fatal("numeric mapping key diagnostic path was not preserved")
}
if lookupCounts["MISSING_SHARED"] != 1 || lookupCounts["EMPTY_SHARED"] != 1 || len(lookupCounts) != 2 {
t.Fatalf("environment lookup counts = %#v, want one lookup per repeated name", lookupCounts)
}
}
func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) { func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
type config struct { type config struct {
Name string `yaml:"name"` Name string `yaml:"name"`

View File

@@ -30,7 +30,6 @@ func validOverlayName(name string) bool {
type selectedOverlayRequest struct { type selectedOverlayRequest struct {
name string name string
filesystem resolvedFilesystemContext
protectionRule *VersionRule protectionRule *VersionRule
sequencePolicy sequencePolicy sequencePolicy sequencePolicy
limits importLimits limits importLimits
@@ -133,7 +132,7 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
var failures []Diagnostic var failures []Diagnostic
if versionState != nil { if versionState != nil {
processed, failures = versionState.processWithDirectiveContext( processed, failures = versionState.processWithDirectiveContext(
overlayRoot, sourceVersionContribution, Path{}, sourceID, placementModeMapping, rootDirectiveContextOverlay, overlayRoot, sourceVersionContribution, Path{}, sourceID, PlacementModeMapping, rootDirectiveContextOverlay,
) )
} else { } else {
processed = processSourceVersionWithDirectiveContext( processed = processSourceVersionWithDirectiveContext(

View File

@@ -590,7 +590,7 @@ func TestLoadOverlayCountsAgainstAggregateBudgets(t *testing.T) {
t.Fatal(diagnostic) t.Fatal(diagnostic)
} }
request := selectedOverlayRequest{ request := selectedOverlayRequest{
name: "testing", filesystem: resolved, limits: test.limits(main, len(overlayData)), name: "testing", limits: test.limits(main, len(overlayData)),
} }
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil { if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
t.Fatal(diagnostic) t.Fatal(diagnostic)

View File

@@ -23,7 +23,6 @@ type sequenceCombinationResult struct {
higherItems []sequenceItemProjection higherItems []sequenceItemProjection
replacements []sequenceItemReplacement replacements []sequenceItemReplacement
appendedHigher []int appendedHigher []int
discardedLower []int
} }
func combineSequence( func combineSequence(
@@ -36,7 +35,7 @@ func combineSequence(
} }
switch policy.operation { switch policy.operation {
case sequenceModeReplace: case sequenceModeReplace:
return replaceSequence(lower, higher, policy.effectiveIdentityFields, context) return replaceSequence(higher, policy.effectiveIdentityFields, context)
case sequenceModeAppend, sequenceModeUpsert: case sequenceModeAppend, sequenceModeUpsert:
return combineIdentifiedSequences(lower, higher, policy.operation, policy.effectiveIdentityFields, context) return combineIdentifiedSequences(lower, higher, policy.operation, policy.effectiveIdentityFields, context)
default: default:
@@ -55,7 +54,7 @@ func unchangedSequenceValue(lower *document.Node) sequenceCombinationResult {
} }
func replaceSequence( func replaceSequence(
lower, higher *document.Node, higher *document.Node,
identityFields []string, identityFields []string,
context sequenceCombinationContext, context sequenceCombinationContext,
) (sequenceCombinationResult, []Diagnostic) { ) (sequenceCombinationResult, []Diagnostic) {
@@ -68,9 +67,6 @@ func replaceSequence(
if higher.Kind == document.Sequence { if higher.Kind == document.Sequence {
result.higherItems = sequenceItemProjections(len(higher.Sequence), 0) result.higherItems = sequenceItemProjections(len(higher.Sequence), 0)
} }
if lower != nil && lower.Kind == document.Sequence {
result.discardedLower = sequenceIndexes(len(lower.Sequence))
}
return result, nil return result, nil
} }

View File

@@ -19,13 +19,12 @@ func TestCombineSequenceOperationsAndItemEvidence(t *testing.T) {
wantHigher []sequenceItemProjection wantHigher []sequenceItemProjection
wantReplacements []sequenceItemReplacement wantReplacements []sequenceItemReplacement
wantAppended []int wantAppended []int
wantDiscarded []int
}{ }{
{ {
name: "replace uses only higher records", name: "replace uses only higher records",
lower: "items: [{id: a}, {id: b}]\n", higher: "items: [{id: b, text: revised}]\n", lower: "items: [{id: a}, {id: b}]\n", higher: "items: [{id: b, text: revised}]\n",
operation: sequenceModeReplace, want: []string{"b"}, operation: sequenceModeReplace, want: []string{"b"},
wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 0}}, wantDiscarded: []int{0, 1}, wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 0}},
}, },
{ {
name: "append retains then appends", name: "append retains then appends",
@@ -57,7 +56,7 @@ func TestCombineSequenceOperationsAndItemEvidence(t *testing.T) {
if got := sequenceIdentityValues(result.value, "id"); !reflect.DeepEqual(got, test.want) { if got := sequenceIdentityValues(result.value, "id"); !reflect.DeepEqual(got, test.want) {
t.Fatalf("combined IDs = %#v, want %#v", got, test.want) t.Fatalf("combined IDs = %#v, want %#v", got, test.want)
} }
if !reflect.DeepEqual(result.lowerItems, test.wantLower) || !reflect.DeepEqual(result.higherItems, test.wantHigher) || !reflect.DeepEqual(result.replacements, test.wantReplacements) || !reflect.DeepEqual(result.appendedHigher, test.wantAppended) || !reflect.DeepEqual(result.discardedLower, test.wantDiscarded) { if !reflect.DeepEqual(result.lowerItems, test.wantLower) || !reflect.DeepEqual(result.higherItems, test.wantHigher) || !reflect.DeepEqual(result.replacements, test.wantReplacements) || !reflect.DeepEqual(result.appendedHigher, test.wantAppended) {
t.Fatalf("item evidence = %#v", result) t.Fatalf("item evidence = %#v", result)
} }
}) })
@@ -112,7 +111,7 @@ func TestReplaceSequenceAllowsNullAndNonSequenceValues(t *testing.T) {
lower := sequenceFromYAML(t, "items: [{id: lower}]\n") lower := sequenceFromYAML(t, "items: [{id: lower}]\n")
higher := sequenceFromYAML(t, test.higher) higher := sequenceFromYAML(t, test.higher)
result, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(sequenceModeReplace), sequenceCombinationContext{destination: Keys("items")}) result, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(sequenceModeReplace), sequenceCombinationContext{destination: Keys("items")})
if len(diagnostics) != 0 || result.value == nil || result.value.Kind != test.wantKind || !reflect.DeepEqual(result.discardedLower, []int{0}) { if len(diagnostics) != 0 || result.value == nil || result.value.Kind != test.wantKind {
t.Fatalf("replacement result = %#v, %#v", result, diagnostics) t.Fatalf("replacement result = %#v, %#v", result, diagnostics)
} }
}) })
@@ -172,7 +171,7 @@ func TestCombineSequenceReplacementResetsLowerIdentity(t *testing.T) {
higher := sequenceFromYAML(t, "items: [{name: new}]\n") higher := sequenceFromYAML(t, "items: [{name: new}]\n")
policy := sequenceCombinationPolicy(sequenceModeReplace, "name") policy := sequenceCombinationPolicy(sequenceModeReplace, "name")
result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")}) result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")})
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "name"), []string{"new"}) || !reflect.DeepEqual(result.discardedLower, []int{0}) { if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "name"), []string{"new"}) {
t.Fatalf("replacement applied obsolete identity: %#v, %#v", result, diagnostics) t.Fatalf("replacement applied obsolete identity: %#v, %#v", result, diagnostics)
} }
} }

View File

@@ -11,16 +11,15 @@ const (
) )
type sequenceDeclarationSpec struct { type sequenceDeclarationSpec struct {
path Path path Path
location Location location Location
destination Path destination Path
destinationLocation Location identityFields []string
identityFields []string identityPresent bool
identityPresent bool identityLocation *Location
identityLocation *Location mode sequenceMode
mode sequenceMode modePresent bool
modePresent bool modeLocation *Location
modeLocation *Location
} }
func parseSequenceDeclarationDirective(node *document.Node, directivePath Path) ([]sequenceDeclarationSpec, []Diagnostic) { func parseSequenceDeclarationDirective(node *document.Node, directivePath Path) ([]sequenceDeclarationSpec, []Diagnostic) {
@@ -60,7 +59,6 @@ func parseSequenceDeclarationSpec(node *document.Node, declarationPath Path) (se
return sequenceDeclarationSpec{}, []Diagnostic{*diagnostic} return sequenceDeclarationSpec{}, []Diagnostic{*diagnostic}
} }
declaration.destination = destination declaration.destination = destination
declaration.destinationLocation = publicLocation(entry.Value.Location)
case "key": case "key":
fields, locations, errIndex, valid := parseSequenceIdentityFieldSet(entry.Value) fields, locations, errIndex, valid := parseSequenceIdentityFieldSet(entry.Value)
if !valid { if !valid {

View File

@@ -24,14 +24,14 @@ func TestParseSequenceDeclarationPresenceAndRelativeDestination(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
extracted := extractSequenceDeclarationsYAML(t, "configkit:\n sequences:\n - "+test.declaration+"\n", rootDirectiveContextSource) extracted := extractSequenceDeclarationsYAML(t, "configkit:\n sequences:\n - "+test.declaration+"\n", rootDirectiveContextSource)
if !extracted.sequencesPresent || len(extracted.sequences) != 1 { if len(extracted.sequences) != 1 {
t.Fatalf("extraction = %#v", extracted) t.Fatalf("extraction = %#v", extracted)
} }
declaration := extracted.sequences[0] declaration := extracted.sequences[0]
if declaration.destination.String() != "/prompts" || declaration.path.String() != "/configkit/sequences/0" { if declaration.destination.String() != "/prompts" || declaration.path.String() != "/configkit/sequences/0" {
t.Fatalf("relative destination or declaration path changed: %#v", declaration) t.Fatalf("relative destination or declaration path changed: %#v", declaration)
} }
if declaration.location.SourceID != "sequence-declarations" || declaration.location.Line == 0 || declaration.destinationLocation.SourceID != "sequence-declarations" || declaration.destinationLocation.Line == 0 { if declaration.location.SourceID != "sequence-declarations" || declaration.location.Line == 0 {
t.Fatalf("declaration source locations were not retained: %#v", declaration) t.Fatalf("declaration source locations were not retained: %#v", declaration)
} }
if declaration.identityPresent != test.wantIdentity || declaration.modePresent != test.wantMode { if declaration.identityPresent != test.wantIdentity || declaration.modePresent != test.wantMode {
@@ -63,13 +63,13 @@ func TestParseSequenceDeclarationNormalizesEquivalentFieldSets(t *testing.T) {
} }
} }
func TestExtractRootDirectivesCombinesImportsAndSequencesWithPresence(t *testing.T) { func TestExtractRootDirectivesCombinesAndRemovesImportsAndSequences(t *testing.T) {
extracted := extractSequenceDeclarationsYAML(t, `configkit: extracted := extractSequenceDeclarationsYAML(t, `configkit:
imports: [{path: fragments/base.yaml}] imports: [{path: fragments/base.yaml}]
sequences: [] sequences: []
value: true value: true
`, rootDirectiveContextSource) `, rootDirectiveContextSource)
if !extracted.importsPresent || len(extracted.imports) != 1 || !extracted.sequencesPresent || len(extracted.sequences) != 0 { if len(extracted.imports) != 1 || len(extracted.sequences) != 0 {
t.Fatalf("mixed extraction = %#v", extracted) t.Fatalf("mixed extraction = %#v", extracted)
} }
if extracted.body == nil || extracted.body.Kind != document.Mapping || len(extracted.body.Mapping) != 1 || extracted.body.Mapping[0].Key != "value" { if extracted.body == nil || extracted.body.Kind != document.Mapping || len(extracted.body.Mapping) != 1 || extracted.body.Mapping[0].Key != "value" {
@@ -89,7 +89,7 @@ func TestExtractRootDirectivesAllowsSequencesButRejectsOverlayImports(t *testing
extracted, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `configkit: extracted, diagnostics := extractRootDirectives(parseRootDirectiveDocument(t, `configkit:
sequences: [{destination: [prompts], key: id}] sequences: [{destination: [prompts], key: id}]
`), rootDirectiveContextOverlay) `), rootDirectiveContextOverlay)
if len(diagnostics) != 0 || !extracted.sequencesPresent || len(extracted.sequences) != 1 { if len(diagnostics) != 0 || len(extracted.sequences) != 1 {
t.Fatalf("overlay sequence extraction = %#v, %#v", extracted, diagnostics) t.Fatalf("overlay sequence extraction = %#v, %#v", extracted, diagnostics)
} }
} }

View File

@@ -16,6 +16,7 @@ func projectTierHistoryApplications(applications []historyApplication, operation
projected := cloneHistoryApplications(applications) projected := cloneHistoryApplications(applications)
for index, original := range applications { for index, original := range applications {
application := &projected[index] application := &projected[index]
var originalLookup *documentLookup
if path, found := projectSequenceOperationPath(original.path, projections); found { if path, found := projectSequenceOperationPath(original.path, projections); found {
application.path = path application.path = path
} }
@@ -25,7 +26,10 @@ func projectTierHistoryApplications(applications []historyApplication, operation
if !active || operation.mode != sequenceHistoryMapped { if !active || operation.mode != sequenceHistoryMapped {
continue continue
} }
value, found := documentAtPath(original.value, source.path) if originalLookup == nil {
originalLookup = buildDocumentLookup(original.value)
}
value, found := documentAtPathWithLookup(original.value, source.path, originalLookup)
if !found || value.Kind != document.Sequence { if !found || value.Kind != document.Sequence {
continue continue
} }

View File

@@ -0,0 +1,79 @@
package engine
import (
"reflect"
"testing"
)
type sliceViewConfig struct {
Value []any `yaml:"value"`
}
func finiteSliceView() []any {
shared := []any{"leaf", nil}
shared[1] = shared[:1]
return shared
}
func TestFiniteSliceViewsAreAcceptedAcrossValueBoundaries(t *testing.T) {
want := []any{"leaf", []any{"leaf"}}
mappingSource := Mapping("mapping.yml", map[string]any{"value": finiteSliceView()})
mapping, err := testLoad[sliceViewConfig](mappingSource)
if err != nil || !reflect.DeepEqual(mapping.Config.Value, want) {
t.Fatalf("mapping value = %#v, error = %v", mapping, err)
}
defaults, err := testLoad[sliceViewConfig](NoSource(), Options[sliceViewConfig]{Definition: Definition[sliceViewConfig]{
Defaults: func() sliceViewConfig { return sliceViewConfig{Value: finiteSliceView()} },
}})
if err != nil || !reflect.DeepEqual(defaults.Config.Value, want) {
t.Fatalf("typed default value = %#v, error = %v", defaults, err)
}
override, err := testLoad[sliceViewConfig](NoSource(), Options[sliceViewConfig]{Overrides: []Override{{
Path: Keys("value"), Value: finiteSliceView(), Flag: "--value",
}}})
if err != nil || !reflect.DeepEqual(override.Config.Value, want) {
t.Fatalf("CLI override value = %#v, error = %v", override, err)
}
original := sliceViewConfig{Value: finiteSliceView()}
schema := mustSchemaFor[sliceViewConfig](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(original), defaultProjectionLimits())
if issue != nil {
t.Fatalf("finite view projection issue = %#v", issue)
}
cloned, issue := cloneConfiguration(schema, reflect.ValueOf(original), defaultProjectionLimits())
if issue != nil {
t.Fatalf("finite view clone issue = %#v", issue)
}
if !reflect.DeepEqual(cloned.Interface(), original) || !reflect.DeepEqual(projected, mappingSource.mapping) {
t.Fatalf("equivalent finite values differ: clone=%#v projected=%#v mapping=%#v", cloned.Interface(), projected, mappingSource.mapping)
}
cloned.FieldByName("Value").Index(1).Interface().([]any)[0] = "changed"
if original.Value[0] != "leaf" || original.Value[1].([]any)[0] != "leaf" {
t.Fatal("mutating a cloned descendant changed the original value")
}
}
func TestSliceCyclesRemainRejected(t *testing.T) {
schema := mustSchemaFor[sliceViewConfig](t)
self := make([]any, 1)
self[0] = self
first := make([]any, 1)
second := make([]any, 1)
first[0], second[0] = second, first
for name, value := range map[string][]any{"self": self, "mutual": first} {
t.Run(name, func(t *testing.T) {
configuration := sliceViewConfig{Value: value}
if _, issue := projectConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("projection cycle issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("clone cycle issue = %#v", issue)
}
})
}
}

View File

@@ -26,7 +26,6 @@ const (
type sourceVersionAncestor struct { type sourceVersionAncestor struct {
path Path path Path
kind document.Kind
} }
type sourceVersionEvidence struct { type sourceVersionEvidence struct {
@@ -38,8 +37,6 @@ type sourceVersionEvidence struct {
version Version version Version
location *Location location *Location
ancestors []sourceVersionAncestor ancestors []sourceVersionAncestor
absence sourceVersionPathAbsence
checked bool
redacted bool redacted bool
} }
@@ -120,19 +117,19 @@ func (evidence *sourceVersionEvidence) sensitivityPaths() []Path {
return paths return paths
} }
func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement placementMode) (sourceVersionProcessingResult, []Diagnostic) { func (state *sourceVersionState) process(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement PlacementMode) (sourceVersionProcessingResult, []Diagnostic) {
if state == nil || state.mode == sourceVersionModeDefault { if state == nil || state.mode == sourceVersionModeDefault {
return sourceVersionProcessingResult{}, nil return sourceVersionProcessingResult{}, nil
} }
return state.processWithDirectiveContext(root, role, destination, sourceID, placement, rootDirectiveContextImportsOnly) return state.processWithDirectiveContext(root, role, destination, sourceID, placement, rootDirectiveContextImportsOnly)
} }
func (state *sourceVersionState) processWithDirectiveContext(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement placementMode, directiveContext rootDirectiveContext) (sourceVersionProcessingResult, []Diagnostic) { func (state *sourceVersionState) processWithDirectiveContext(root *document.Node, role sourceVersionRole, destination Path, sourceID string, placement PlacementMode, directiveContext rootDirectiveContext) (sourceVersionProcessingResult, []Diagnostic) {
if state == nil { if state == nil {
return sourceVersionProcessingResult{}, nil return sourceVersionProcessingResult{}, nil
} }
projectedDestination := destination.clone() projectedDestination := destination.clone()
if placement == placementModeSequenceItem { if placement == PlacementModeSequenceItem {
projectedDestination = projectedDestination.Index(0) projectedDestination = projectedDestination.Index(0)
} }
result := processSourceVersionWithDirectiveContext(root, state.localRule, role, projectedDestination, state.mode, directiveContext) result := processSourceVersionWithDirectiveContext(root, state.localRule, role, projectedDestination, state.mode, directiveContext)
@@ -161,10 +158,10 @@ type sourceVersionProcessingResult struct {
} }
func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule { func effectiveSourceVersionRule(local *VersionRule, destination Path, mode sourceVersionMode) *VersionRule {
return effectiveSourceVersionRuleForPlacement(local, destination, mode, placementModeMapping) return effectiveSourceVersionRuleForPlacement(local, destination, mode, PlacementModeMapping)
} }
func effectiveSourceVersionRuleForPlacement(local *VersionRule, destination Path, mode sourceVersionMode, placement placementMode) *VersionRule { func effectiveSourceVersionRuleForPlacement(local *VersionRule, destination Path, mode sourceVersionMode, placement PlacementMode) *VersionRule {
if local == nil { if local == nil {
return nil return nil
} }
@@ -173,7 +170,7 @@ func effectiveSourceVersionRuleForPlacement(local *VersionRule, destination Path
} }
effective := *local effective := *local
prefix := destination.clone() prefix := destination.clone()
if placement == placementModeSequenceItem { if placement == PlacementModeSequenceItem {
prefix = prefix.Index(0) prefix = prefix.Index(0)
} }
effective.Path = Path{ effective.Path = Path{
@@ -239,7 +236,6 @@ func processSourceVersionWithDirectiveContext(root *document.Node, rule *Version
evidence := &sourceVersionEvidence{ evidence := &sourceVersionEvidence{
path: rule.Path.clone(), resolvedPath: resolved.resolvedPath, path: rule.Path.clone(), resolvedPath: resolved.resolvedPath,
destination: destination.clone(), ancestors: resolved.ancestors, destination: destination.clone(), ancestors: resolved.ancestors,
absence: resolved.absence,
} }
location := resolved.nearest location := resolved.nearest
if resolved.present { if resolved.present {
@@ -277,7 +273,6 @@ func processSourceVersionWithDirectiveContext(root *document.Node, rule *Version
} }
} }
evidence.checked = true
result.diagnostics = canonicalDiagnostics(reportVersion(rule, interpreted.version, root)) result.diagnostics = canonicalDiagnostics(reportVersion(rule, interpreted.version, root))
if hasErrors(result.diagnostics) { if hasErrors(result.diagnostics) {
evidence.redactText() evidence.redactText()
@@ -334,7 +329,7 @@ func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath
} }
current := root current := root
resolved.nearest = current.Location resolved.nearest = current.Location
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{kind: current.Kind}) resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{})
for position, segment := range path.segments { for position, segment := range path.segments {
switch current.Kind { switch current.Kind {
case document.Mapping: case document.Mapping:
@@ -373,7 +368,7 @@ func resolveSourceVersionPath(root *document.Node, path Path) sourceVersionPath
} }
resolved.nearest = current.Location resolved.nearest = current.Location
resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{ resolved.ancestors = append(resolved.ancestors, sourceVersionAncestor{
path: Path{segments: clonePathSegments(resolved.resolvedPath.segments[:position+1], 0)}, kind: current.Kind, path: Path{segments: clonePathSegments(resolved.resolvedPath.segments[:position+1], 0)},
}) })
} }
resolved.node = current resolved.node = current

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