Compare commits
66 Commits
d898cb193d
...
v0.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 453da684d7 | |||
| fb6c254d38 | |||
| a71413ce67 | |||
| d66338a1c5 | |||
| 643f43ce6a | |||
| c25cf39d19 | |||
| 7d18c93339 | |||
| b1d89810ca | |||
| bb5540b62e | |||
| 80cf23e710 | |||
| ac48557535 | |||
| b4af900c10 | |||
| f132cd6468 | |||
| 75e209eb3b | |||
| 1dd94a3cc6 | |||
| d172a5e46a | |||
| 3a1b6ff355 | |||
| 68383f34de | |||
| 549fca239d | |||
| 883244936c | |||
| a8ae2e9d5e | |||
| f18582106b | |||
| d8bf6abc2b | |||
| 4bf940c579 | |||
| 4fd3bee986 | |||
| 6f6130fd12 | |||
| 3122e2f32d | |||
| e4a5ecb4be | |||
| 826062aa17 | |||
| 08f2a8f3e9 | |||
| 12d350a292 | |||
| 4fcff5f453 | |||
| 247a12bc50 | |||
| 119b42c810 | |||
| 0f9ecf12ed | |||
| 7bf2f0c687 | |||
| 8aba43da38 | |||
| 877c497325 | |||
| 08a8c25542 | |||
| ea27f17f5c | |||
| ae3408a096 | |||
| deb9aa5c69 | |||
| 2736ddfbf2 | |||
| 7889ebddfb | |||
| 4a5316b7fc | |||
| e1e94eeec7 | |||
| 176e9a5d90 | |||
| 23c1976ef1 | |||
| aab79d9508 | |||
| ecd5d5d460 | |||
| b1c278b38b | |||
| eb5a82f1f3 | |||
| b30c9f167d | |||
| 5925066d35 | |||
| 0cca633a67 | |||
| a80273433e | |||
| 320d0c5f43 | |||
| 5098b56655 | |||
| 47944d22d1 | |||
| 50199eb0af | |||
| 34cbc0d63b | |||
| 5e12b2d360 | |||
| 8dfaf44d1d | |||
| 64be8b4df5 | |||
| e585decdb2 | |||
| 6440a500f7 |
@@ -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
|
||||||
|
|||||||
@@ -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), [consolidated audit review](roadmap/audit.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
|
||||||
@@ -189,10 +189,21 @@ remediation record are preserved in Git history at `0369111`, for example
|
|||||||
[configuration discovery](config.md#configuration-discovery); the public
|
[configuration discovery](config.md#configuration-discovery); the public
|
||||||
[discovery declarations](../discovery.go) own the Go API.
|
[discovery declarations](../discovery.go) own the Go API.
|
||||||
|
|
||||||
The [consolidation roadmap](roadmap/consolidation.md) owns the current refactoring
|
The completed consolidation proposal, implementation plan, and resolved audit
|
||||||
proposal and the associated complexity-audit findings.
|
findings are preserved in Git history at `34cbc0d` under `docs/roadmap/`. For
|
||||||
|
example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 34cbc0d:docs/roadmap/implementation.md
|
||||||
|
git show 34cbc0d:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||||
|
The [consolidated audit review](roadmap/audit.md) owns the open findings from
|
||||||
|
the nine 2026-10-25 audits, their evidence, and settled scope decisions. The
|
||||||
|
[remediation roadmap](roadmap/roadmap.md) owns the combined target state and
|
||||||
|
high-level dependency order. The original reports are preserved at `ecd5d5d`
|
||||||
|
under `docs/roadmap/`.
|
||||||
|
|
||||||
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
|
||||||
@@ -250,7 +261,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||||
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
||||||
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||||
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go), [deferred clone review](roadmap/future.md#4-clone-traversal-review) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ This note records implementation flow and private identities for maintainers.
|
|||||||
|
|
||||||
Each source's root directive is parsed into presence-aware declarations and
|
Each source's root directive is parsed into presence-aware declarations and
|
||||||
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
|
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
|
||||||
policy, load accounting, and per-tier accounting; `import_acquisition.go` owns
|
policy, load-wide accounting, and tier-scoped repetition/namespace state;
|
||||||
reading candidate bytes, declaration traversal, and import admission. Candidate
|
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
|
||||||
|
and import admission. Candidate
|
||||||
selection in `import_selection.go` preserves a normalized
|
selection in `import_selection.go` preserves a normalized
|
||||||
namespace path separately from canonical access identity: overlapping roots
|
namespace path separately from canonical access identity: overlapping roots
|
||||||
coalesce the same candidate spelling in that namespace, while aliases reached
|
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||||
@@ -32,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
|
||||||
@@ -71,6 +92,12 @@ tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
|
|||||||
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||||
reported as cycle members.
|
reported as cycle members.
|
||||||
|
|
||||||
|
Import edge preparation builds an importer-to-target adjacency index once for
|
||||||
|
an acquisition's outer and item-local scheduling, and once for grouped
|
||||||
|
scheduling. Each scope gathers edges from its participating identities and
|
||||||
|
retains only edges with both endpoints in its local slots. The retained
|
||||||
|
inclusion graph still owns cycle diagnostics and source relationships.
|
||||||
|
|
||||||
Import composition schedules a main tier or one underlay group as a single
|
Import composition schedules a main tier or one underlay group as a single
|
||||||
strict-scope graph. Applicable collection declarations are gathered for the
|
strict-scope graph. Applicable collection declarations are gathered for the
|
||||||
scope before peer composition, including identity inherited from lower tiers.
|
scope before peer composition, including identity inherited from lower tiers.
|
||||||
|
|||||||
@@ -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,20 +95,31 @@ 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
|
||||||
source, filesystem, destination, and import settings.
|
source, filesystem, destination, and import settings.
|
||||||
|
|
||||||
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
||||||
policy and load-wide and per-tier accounting state. Each input is acquired under
|
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
||||||
that policy; grouped requests retain acquired underlay scopes until their tier is
|
input is acquired under that policy; grouped requests retain acquired underlay
|
||||||
composed in [`load_request.go`](../../internal/engine/load_request.go). Live
|
scopes until their tier is composed in
|
||||||
|
[`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: see
|
resource contexts. Non-import validation retains those facts on `assembledInput`
|
||||||
|
for passive-context construction and ordinary-overlay handling: see
|
||||||
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
|
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
|
||||||
[`source_context.go`](../../internal/engine/source_context.go).
|
[`source_context.go`](../../internal/engine/source_context.go).
|
||||||
|
|
||||||
@@ -140,5 +156,5 @@ covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
|||||||
discovery, and destination tests; inheritance by the inheritance test files;
|
discovery, and destination tests; inheritance by the inheritance test files;
|
||||||
reflection behavior by `schema_test.go`, `projection_test.go`, and
|
reflection behavior by `schema_test.go`, `projection_test.go`, and
|
||||||
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
||||||
links focused commands. Typed clone extraction remains deferred in
|
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
|
||||||
[`future.md`](../roadmap/future.md#4-clone-traversal-review).
|
copying, using the shared descriptors and per-operation traversal helpers.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
42
docs/releases/v0.10.1.md
Normal file
42
docs/releases/v0.10.1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ConfigKit v0.10.1
|
||||||
|
|
||||||
|
This patch release consolidates ConfigKit's internal loading machinery and
|
||||||
|
reduces repeated import bookkeeping. Public APIs and configuration semantics
|
||||||
|
remain unchanged from v0.10.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Consolidate ordinary and layered input acquisition, including import requests
|
||||||
|
and selected-overlay handling, while retaining their distinct context and
|
||||||
|
precedence rules. Remove unused private reverse conversions.
|
||||||
|
- Separate import acquisition, scoped composition, and evidence projection;
|
||||||
|
clarify inheritance preparation, combination, and finalization; and simplify
|
||||||
|
reflective projection and schema-field assembly responsibilities.
|
||||||
|
- Retain resolved filesystem facts from non-import validation for later passive
|
||||||
|
resource contexts and ordinary overlays. Remove duplicate absent-host context
|
||||||
|
derivation while preserving validation timing and handle cleanup.
|
||||||
|
- Record empty optional-import selections directly against their declaring
|
||||||
|
source, avoiding repeated searches through acquired sources. Index import
|
||||||
|
dependencies for scope-local edge preparation instead of scanning the whole
|
||||||
|
inclusion graph for every item scope. Stable dependency-ready ordering,
|
||||||
|
provenance, and source destinations are preserved.
|
||||||
|
- Expand regression coverage for import evidence ownership, scope boundaries,
|
||||||
|
and broad main/underlay item imports. Improve
|
||||||
|
[consumer loading guidance](../consumers/README.md) and internal documentation.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No consumer code or configuration migration is required. Composition, collection
|
||||||
|
operations, inheritance, discovery, CLI precedence, source-version checks, and
|
||||||
|
inspection retain their existing contracts.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. Dependencies and platform support are
|
||||||
|
unchanged. Publication follows the existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/docs/oss/attribution.md)
|
||||||
70
docs/releases/v0.10.2.md
Normal file
70
docs/releases/v0.10.2.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ConfigKit v0.10.2
|
||||||
|
|
||||||
|
This patch release fixes edge-case loading failures, resource cleanup, and limit
|
||||||
|
accounting, and simplifies ConfigKit's internal loading machinery. Public APIs
|
||||||
|
and documented configuration semantics remain unchanged from v0.10.1.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Terminate pointer-only recursive type traversal safely during decoding and
|
||||||
|
schema-path resolution, preserving nullable values and supported recursive
|
||||||
|
structures. Roll back failed dynamic descriptor construction so subsequent
|
||||||
|
operations do not reuse incomplete state or panic.
|
||||||
|
- Accept finite, non-cyclic slice views that share backing storage while
|
||||||
|
retaining cycle detection and detached output.
|
||||||
|
- Deactivate resolution, validation, and version-check contexts when callbacks
|
||||||
|
panic, and close library-owned import roots during panic unwinding. The
|
||||||
|
original application panic continues to propagate.
|
||||||
|
- Report directory enumeration and close failures instead of treating them as
|
||||||
|
optional absence, including failures reported as not-exist after a successful
|
||||||
|
open. Genuinely missing optional directories retain their existing behavior.
|
||||||
|
- Reuse completed symlink dependencies during host and native import discovery,
|
||||||
|
avoiding exponential repeated expansion of finite link graphs while retaining
|
||||||
|
cycle detection and containment checks.
|
||||||
|
- Check existing projection budgets before bulk container allocation and account
|
||||||
|
for the complete retained inspection-history graph, including replay and
|
||||||
|
separately copied inheritance evidence.
|
||||||
|
- Apply the documented append/upsert sequence requirements to non-sequence
|
||||||
|
contributions at retained collection-policy destinations.
|
||||||
|
|
||||||
|
## Internal improvements
|
||||||
|
|
||||||
|
- Index collection-policy retirement and prepare sparse incoming policy changes
|
||||||
|
instead of repeatedly scanning or copying unrelated retained policy.
|
||||||
|
- Reuse successful dynamic-type eligibility checks within an operation and index
|
||||||
|
tier-history lookups, compound identity restoration, and secret-failure source
|
||||||
|
attribution to avoid repeated scans.
|
||||||
|
- Remove unused snapshots, evidence, helpers, fields, and test-only production
|
||||||
|
paths. Clarify acquisition handoffs and separate collection preparation,
|
||||||
|
application, and finalization while sharing sequence-history construction.
|
||||||
|
- Separate patterned glob entry preparation from traversal. Consolidate engine
|
||||||
|
import defaults, snapshot them during load preflight, and construct placement
|
||||||
|
directly across the facade while keeping public types root-owned.
|
||||||
|
- Add regression coverage for the corrected failures, public historical paths,
|
||||||
|
defaults snapshots, and resource ownership. Strengthen fuzz detachment checks
|
||||||
|
and replace redundant or implementation-coupled assertions.
|
||||||
|
- Update release tooling from ReleaseKit v0.4.1 to v0.4.2.
|
||||||
|
|
||||||
|
These changes remove redundant work and improve scaling in the identified paths;
|
||||||
|
no end-to-end performance improvement has been quantified.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No consumer API or configuration-format migration is required. The
|
||||||
|
[configuration contract](../config.md) remains authoritative.
|
||||||
|
|
||||||
|
Loads that previously succeeded despite directory acquisition failures, invalid
|
||||||
|
append/upsert contributions, or incomplete retained-history accounting may now
|
||||||
|
return the documented structured errors. These corrections enforce existing
|
||||||
|
requirements and limits rather than introducing new ones.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. The YAML dependency and platform support
|
||||||
|
are unchanged. Publication follows the existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/docs/oss/attribution.md)
|
||||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
|||||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
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
|
||||||
|
|
||||||
|
|||||||
949
docs/roadmap/audit.md
Normal file
949
docs/roadmap/audit.md
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
# Consolidated Audit Review
|
||||||
|
|
||||||
|
Status: scope settled; 29 entries selected for implementation planning.
|
||||||
|
Two refactors are deferred to `future.md`. Implementation has not begun.
|
||||||
|
Consolidated: 2026-10-25. Baseline: `ecd5d5d`.
|
||||||
|
|
||||||
|
This document consolidates nine sequential audits for maintainer review. It owns
|
||||||
|
their open findings, supporting evidence, proposed remedies, and relationships.
|
||||||
|
The settled decisions select the [remediation roadmap's](roadmap.md) scope.
|
||||||
|
That document owns the combined target state and high-level dependency order;
|
||||||
|
this review owns detailed finding evidence and acceptance cases. Neither is a
|
||||||
|
staged implementation plan or a new set of release gates. The
|
||||||
|
[configuration contract](../config.md), public Go declarations, and
|
||||||
|
[architecture policy](../policy/architecture.md) continue to
|
||||||
|
own behavior and guarantees.
|
||||||
|
|
||||||
|
There are nine distinct reported defects: four from correctness, three from
|
||||||
|
resources, one from error handling, and one from security. Concurrency adds
|
||||||
|
evidence for an existing callback-lifetime defect. Other entries are cleanup,
|
||||||
|
efficiency, maintainability, or test improvements. Regression recommendations
|
||||||
|
are attached to the defects they protect rather than counted as additional
|
||||||
|
product fixes.
|
||||||
|
|
||||||
|
The 35 original numbered findings map to 31 review entries after deduplication
|
||||||
|
and integration of related test work. All findings remain unresolved; the
|
||||||
|
[settled scope decisions](#settled-scope-decisions) identify the 29 selected
|
||||||
|
entries. C03 and F07 have moved to `future.md`, with reference entries retained
|
||||||
|
here for traceability. All three scope questions are settled.
|
||||||
|
Their medium/low priorities retain the original assessments; those labels do not
|
||||||
|
imply severity certification or implementation order. IDs remain stable as work
|
||||||
|
is selected or completed.
|
||||||
|
|
||||||
|
## Evidence And Provenance
|
||||||
|
|
||||||
|
All nine audits were dated 2026-10-25. Their sequential commits changed only the
|
||||||
|
audit documents, so their production baseline is the same. This consolidation
|
||||||
|
does not independently reproduce the findings or claim new runtime evidence.
|
||||||
|
|
||||||
|
| Original report | Reviewed revision | Evidence contributed |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `correctness.md` | `47944d2` | Source review and standalone public-API reproductions of four defects. |
|
||||||
|
| `efficiency.md` | `5098b56` | Source-derived scaling cases and proposed algorithm changes; no benchmarks. |
|
||||||
|
| `complexity.md` | `320d0c5` | Responsibility/caller review, graph complexity estimates, and maintainability candidates. |
|
||||||
|
| `concurrency.md` | `a802734` | Ownership review, race checks, and corroboration of callback lifetime after panic. |
|
||||||
|
| `resources.md` | `0cca633` | Bounded allocation, retained-node, and host-handle diagnostics. |
|
||||||
|
| `error-handling.md` | `5925066` | Directory read/close fault injection through the public API. |
|
||||||
|
| `security.md` | `b30c9f1` | Trust-boundary review, bounded symlink operation counts, and a historical dependency spot-check. |
|
||||||
|
| `testing.md` | `eb5a82f` | Aggregate statement coverage, assertion review, and regression/test-cleanup recommendations. |
|
||||||
|
| `deadcode.md` | `b1c278b` | Type-aware reference inventory, graph/caller checks, and source review of private cleanup candidates. |
|
||||||
|
|
||||||
|
The full original reports, including exact probes, commands, source line
|
||||||
|
references, and validation limitations, are preserved at `ecd5d5d`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show ecd5d5d:docs/roadmap/correctness.md
|
||||||
|
git show ecd5d5d:docs/roadmap/security.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The [original-finding map](#original-finding-map) accounts for every numbered
|
||||||
|
finding, including duplicates and test recommendations integrated elsewhere.
|
||||||
|
Source links below identify implementation owners; historical line numbers are
|
||||||
|
left in the originals because later edits will move them.
|
||||||
|
|
||||||
|
### Recorded Validation And Its Limits
|
||||||
|
|
||||||
|
The audits report successful development checks, ordinary tests, full race tests,
|
||||||
|
`go vet`, and focused suites as specified in their individual records, using
|
||||||
|
Go 1.26.10 on Linux amd64. Concurrency additionally repeated six relevant engine
|
||||||
|
tests with race instrumentation, count 20, and CPUs 1, 4, and 8. Testing ran the
|
||||||
|
suite with shuffle enabled three times. These results did not detect the newly
|
||||||
|
reported defects and are not evidence that those defects have been fixed.
|
||||||
|
|
||||||
|
Testing recorded 771 ordinary test functions, three fuzz targets, and 29 executable
|
||||||
|
examples. Aggregate cross-package coverage was 90.3%: root 81.3%, engine 91.0%,
|
||||||
|
document 100%, YAML 89.9%, and maintained consumers 79.2%. Those figures locate
|
||||||
|
gaps; they are not targets or a measure of assertion strength. Only checked-in
|
||||||
|
fuzz seeds ran, not an extended campaign or mutation analysis.
|
||||||
|
|
||||||
|
Standalone diagnostics stayed outside the repository. Some potentially fatal
|
||||||
|
cases used disposable processes, small limits, or operation ceilings; larger
|
||||||
|
scaling examples were derived rather than executed. The dead-code inventory
|
||||||
|
considered Linux, Darwin, and Windows build configurations, but no audit reports
|
||||||
|
native macOS or Windows execution. Graph scores and reachability results were
|
||||||
|
triage aids, not proof that a function is unnecessary or a numerical refactoring
|
||||||
|
threshold. Cached analysis tooling was used outside the repository; no new
|
||||||
|
project tools or dependencies were introduced.
|
||||||
|
|
||||||
|
The security report's module-path advisory spot-check and successful module
|
||||||
|
integrity verification are historical evidence only. It recorded no listed
|
||||||
|
advisories for the selected YAML v4, ReleaseKit, or Goldmark v2 module paths, and
|
||||||
|
recorded the selected `x/mod` version as fixed for the two reports it found. It
|
||||||
|
did not perform complete toolchain/transitive reachability analysis or supply-
|
||||||
|
chain attestation; dedicated vulnerability, security, and secret scanners were
|
||||||
|
unavailable. This consolidation neither refreshes nor broadens that conclusion.
|
||||||
|
|
||||||
|
## Review Priorities And Dependencies
|
||||||
|
|
||||||
|
Recommended review order is reported defects with their regression coverage,
|
||||||
|
bounded cleanup, targeted efficiency changes, and then broader structural
|
||||||
|
refactoring. Small prerequisite cleanups can accompany a defect fix. The order
|
||||||
|
of the groups below is for navigation, not an execution sequence.
|
||||||
|
|
||||||
|
| Relationship | Consequence for scope selection |
|
||||||
|
| --- | --- |
|
||||||
|
| A02 and A05 | Make descriptor construction safe before reusing successful dynamic-type eligibility results. |
|
||||||
|
| B01 and B02 | Panic cleanup must close callback state and the owned host root independently. Neither fix substitutes for the other. |
|
||||||
|
| C01 and its filesystem tests | Strengthen error/handle assertions while adding failure-phase regressions, using one shared test owner. |
|
||||||
|
| C02 and deferred C03 | Complete link expansion remediation independently; reconsider glob readability extraction afterward through `future.md`. |
|
||||||
|
| D01, D02, and D03 | Remove the confirmed unused copies, then account for the complete retained graph. Removing one unused tree does not fix the remaining omissions. |
|
||||||
|
| E01, E02, and E03 | Address policy-state costs and responsibility boundaries together, preserving separate scaling and behavioral acceptance cases. |
|
||||||
|
| F01 and F02 | Removing the test-only acquisition mode does not by itself simplify active lifecycle handoffs. |
|
||||||
|
| F04, F09, and F12 | Replace useful facade ownership/query coverage before deleting disconnected or representation-coupled scaffolding. |
|
||||||
|
| F05 and deferred F07 | Remove unused placement bridges in this scope; reconsider wider transport simplification afterward through `future.md`. |
|
||||||
|
|
||||||
|
## A. Schema And Typed Values
|
||||||
|
|
||||||
|
### A01. Terminate Pointer-Only Recursive Descriptor Traversal
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: correctness 1; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** In [schema.go](../../internal/engine/schema.go), a legal
|
||||||
|
type such as `type Loop *Loop` produces a pointer descriptor whose element is
|
||||||
|
itself. [decode.go](../../internal/engine/decode.go) unwraps that descriptor
|
||||||
|
without consuming a non-null input node; `resolveSchemaPath` likewise assumes
|
||||||
|
pointer traversal eventually reaches another kind. A public load with a `Loop`
|
||||||
|
field and `value: {}` overflowed the stack in a process with a small stack limit.
|
||||||
|
`value: null` succeeded, but resolving a normalization path for that field hung
|
||||||
|
until the diagnostic process was stopped. The [schema
|
||||||
|
contract](../config.md#application-schema-binding) permits recursive types with
|
||||||
|
acyclic values; the inline-field restriction is not a blanket pointer restriction.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Detect repeated descriptors while unwrapping
|
||||||
|
pointer-only chains in decoding and schema-path resolution, and review analogous
|
||||||
|
helpers. Incompatible non-null input must fail safely; path traversal must
|
||||||
|
terminate and reject unaddressable descendants. Preserve supported nil/null
|
||||||
|
values and ordinary recursive structs. Do not add a blanket schema rejection or
|
||||||
|
arbitrary depth cap. Add bounded regression coverage for both decoding and helper
|
||||||
|
path resolution, keeping fatal/hanging probes out of the default test process.
|
||||||
|
|
||||||
|
### A02. Make Failed Dynamic Descriptor Construction Transactional
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: correctness 2; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `dynamicDescriptor` in
|
||||||
|
[value_operation.go](../../internal/engine/value_operation.go) passes the load's
|
||||||
|
descriptor cache to `schemaBuilder`. Failed construction leaves cached container
|
||||||
|
placeholders. For a dynamic struct containing an unsupported channel field, two
|
||||||
|
`Normalize` calls in one resolver, first with a typed nil pointer and then a
|
||||||
|
non-nil pointer to that type, reproduced a nil-pointer panic on the second call.
|
||||||
|
The first call reported a diagnostic but left a reusable partial descriptor.
|
||||||
|
The application callback itself did not panic.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Stage new descriptors and commit only on
|
||||||
|
successful construction, or roll back all additions from a failed build. Include
|
||||||
|
recursive references among newly built descriptors; clearing only the immediate
|
||||||
|
failed container or adding a projection nil guard is insufficient. Preserve
|
||||||
|
previously valid descriptors, load-local ownership, and safe path-specific
|
||||||
|
diagnostics under [typed projection](../config.md#typed-projection-and-ownership)
|
||||||
|
and [resolution](../config.md#recorded-application-resolution). Test repeated
|
||||||
|
operations after a failed build in the same load. Coordinate A05 only after this
|
||||||
|
failure-state boundary is sound.
|
||||||
|
|
||||||
|
### A03. Distinguish Acyclic Slice Views In Cycle Detection
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: correctness 3; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `valueVisit` / `visitFor` in
|
||||||
|
[value_operation.go](../../internal/engine/value_operation.go) identify slices by
|
||||||
|
type, kind, and starting pointer, omitting length. The finite value built by
|
||||||
|
`shared := []any{"leaf", nil}; shared[1] = shared[:1]` was rejected through mapping
|
||||||
|
sources, typed defaults, and CLI overrides, while an equivalent independently
|
||||||
|
allocated value succeeded. The nested view contains only the leaf, not its
|
||||||
|
enclosing view. The same helper serves projection/cloning and
|
||||||
|
[value.go](../../internal/engine/value.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Include visible slice length in active-visit
|
||||||
|
identity, or use an equivalent distinction. Preserve rejection of actual self
|
||||||
|
and mutual cycles, nil/empty behavior, and detached output; preserving alias
|
||||||
|
identity is not required. Regressions should cover the shared source, override,
|
||||||
|
projection, and cloning paths under the existing supported-acyclic-value contract,
|
||||||
|
alongside true-cycle rejection.
|
||||||
|
|
||||||
|
### A04. Enforce Existing Node Budgets Before Container Reservations
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: resources 1; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `projectSequence` and `projectMap` in
|
||||||
|
[projection.go](../../internal/engine/projection.go) reserve space for the full
|
||||||
|
container, and maps collect all keys, before child charging in
|
||||||
|
[value_operation.go](../../internal/engine/value_operation.go). Defaults containing
|
||||||
|
a maximum-length slice of zero-sized structs caused a `makeslice: cap out of range`
|
||||||
|
panic instead of `schema.projection_limit`; that diagnostic did not allocate a
|
||||||
|
huge input backing array. Less extreme cases can allocate excessive temporary
|
||||||
|
memory before rejection. Application resolution can reach these paths too.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Check the known minimum node cost against the
|
||||||
|
remaining existing allowance with overflow-safe arithmetic, or bound initial
|
||||||
|
capacity and grow as charging succeeds. Review map reservation/key collection as
|
||||||
|
well as sequences. Preserve node/depth semantics, inline handling, and useful
|
||||||
|
diagnostic paths. Use bounded regressions, including small controlled allowances,
|
||||||
|
without enormous allocations. This enforces an existing budget, not a new length
|
||||||
|
cap or universal heap-size guarantee.
|
||||||
|
|
||||||
|
### A05. Reuse Successful Dynamic-Type Eligibility Checks Locally
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: low. Source: efficiency 6.
|
||||||
|
|
||||||
|
**Problem and evidence.** Each dynamic value in projection/cloning invokes
|
||||||
|
`descriptorContainsSecret` with a new visited map, even when its descriptor is
|
||||||
|
cached. N values of one concrete type with descriptor-graph size S therefore cost
|
||||||
|
O(N × S) eligibility work. N interface values containing nil pointers to one wide
|
||||||
|
supported struct can produce only N null values while repeating the full type
|
||||||
|
walk. This is a source-derived bound, not measured runtime.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** After A02, consider remembering successfully
|
||||||
|
approved concrete descriptors within `valueOperation`, reducing repeated-type
|
||||||
|
eligibility work to O(S + N). Cache only completed successful eligibility checks,
|
||||||
|
not traversal-local visits, partial descriptors, or path-bearing failures.
|
||||||
|
Preserve rejection of dynamic secret-reference types, including nil pointers and
|
||||||
|
recursive descriptor cases. This does not select cross-load schema caching or a
|
||||||
|
clone traversal redesign.
|
||||||
|
|
||||||
|
## B. Callback And Filesystem Lifetime
|
||||||
|
|
||||||
|
### B01. Deactivate Callback Contexts During Panic Unwinding
|
||||||
|
|
||||||
|
Type: reported defect. Priority: low. Sources: correctness 4, concurrency 1;
|
||||||
|
testing 1; corroborated by error-handling and security reviews.
|
||||||
|
|
||||||
|
**Problem and evidence.** `runResolution` in
|
||||||
|
[resolution.go](../../internal/engine/resolution.go) and `runValidation` /
|
||||||
|
`reportVersion` in [validation.go](../../internal/engine/validation.go) deactivate
|
||||||
|
contexts only after normal callback return. Public probes retained the context,
|
||||||
|
panicked, recovered outside `Load`, and then successfully queried resolution or
|
||||||
|
wrote validation/version diagnostics. The concurrency audit reproduced this
|
||||||
|
with race instrumentation without reporting a data race. The defect violates
|
||||||
|
callback lifetime, not a newly supported concurrent-use scenario.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Defer deactivation within each callback
|
||||||
|
invocation, closing immediately at normal return and on unwinding, before later
|
||||||
|
projection or metadata work. Preserve the original panic and prohibit retained
|
||||||
|
context use through the established closed-context behavior. Test all three
|
||||||
|
callback kinds with external recovery. No recovery service, lock, atomic flag,
|
||||||
|
or broader callback thread-safety guarantee is needed. B02 separately owns root
|
||||||
|
handle cleanup.
|
||||||
|
|
||||||
|
### B02. Close The Owned Import Root When A Version Callback Panics
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: resources 3; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** Host import acquisition in
|
||||||
|
[import_acquisition.go](../../internal/engine/import_acquisition.go) opens an
|
||||||
|
`os.Root` through [filesystem_access.go](../../internal/engine/filesystem_access.go)
|
||||||
|
but relies on explicit ordinary-path closes. An imported version-check panic
|
||||||
|
bypasses them. A disposable Linux probe disabled GC, recovered outside `Load`,
|
||||||
|
and observed one remaining root descriptor through `/proc/self/fd`; the normal
|
||||||
|
control returned to zero. The imported file's own deferred close still ran.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Install deferred fallback cleanup at the
|
||||||
|
owning boundary, preserving explicit normal close timing and diagnostics, as
|
||||||
|
selected-overlay acquisition already does. Avoid double-close confusion, leave
|
||||||
|
borrowed filesystems untouched, and propagate the original panic. A regression
|
||||||
|
must observe the owned host root, not merely a native file-close counter, and
|
||||||
|
document platform prerequisites. Context deactivation in B01 is a separate
|
||||||
|
requirement. This restores load-scope cleanup without adding panic recovery.
|
||||||
|
|
||||||
|
## C. Import Selection And Link Resolution
|
||||||
|
|
||||||
|
### C01. Preserve Directory Failure Phase And Strengthen Its Tests
|
||||||
|
|
||||||
|
Type: reported defect with test improvements. Priority: medium.
|
||||||
|
Sources: error-handling 1, testing 3.
|
||||||
|
|
||||||
|
**Problem and evidence.** `filesystemAccess.readDirectory` in
|
||||||
|
[filesystem_access.go](../../internal/engine/filesystem_access.go) returns open,
|
||||||
|
read, and close errors through one unqualified error channel.
|
||||||
|
[import_selection.go](../../internal/engine/import_selection.go) and
|
||||||
|
[glob.go](../../internal/engine/glob.go) treat any `fs.ErrNotExist` as absence.
|
||||||
|
A borrowed filesystem probe selected a failing directory plus a good directory,
|
||||||
|
using both `dir` and `glob` with `required: true`. Injecting `fs.ErrNotExist`
|
||||||
|
alongside entries from `ReadDir`, or from `Close`, let loads succeed with the bad
|
||||||
|
directory's contribution omitted. Permission-classified controls failed. All
|
||||||
|
eight selector/phase/error cases ran. This violates the existing distinction
|
||||||
|
between optional absence and failed acquisition.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Preserve missing-on-open versus post-open
|
||||||
|
failure, for example with a small private failure record analogous to regular
|
||||||
|
file acquisition. Keep genuine missing-target rules, entry budgets, first-error
|
||||||
|
precedence, borrowed filesystem ownership, and safe existing traversal diagnostics.
|
||||||
|
Review both batched `fs.ReadDirFile` and standard fallback paths. A successful
|
||||||
|
second directory must not hide enumeration/close failure; raw backing error text
|
||||||
|
must not leak. No retries or filesystem transaction are proposed.
|
||||||
|
|
||||||
|
**Test improvements.** The shared fixture in
|
||||||
|
[native_filesystem_test.go](../../internal/engine/native_filesystem_test.go) and
|
||||||
|
[resource_context_test.go](../../internal/engine/resource_context_test.go) currently
|
||||||
|
checks aggregate closes and often only non-nil errors. One leaked handle and
|
||||||
|
one double-close can cancel out. Its canary check examines the terse error
|
||||||
|
summary, not structured diagnostics. Track per-handle closure, assert nil result,
|
||||||
|
diagnostic code and source, and inspect diagnostic messages/rendering for canaries.
|
||||||
|
Add entries/data-plus-error and close-phase cases through narrow failure seams.
|
||||||
|
Preserve intentionally ignored read-only regular-file close errors; this does
|
||||||
|
not impose a universal close-error policy.
|
||||||
|
|
||||||
|
### C02. Avoid Reexpanding Completed Finite Symlink Dependencies
|
||||||
|
|
||||||
|
Type: reported availability defect. Priority: medium. Source: security 1;
|
||||||
|
testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** Host/native resolvers in
|
||||||
|
[filesystem_links.go](../../internal/engine/filesystem_links.go) track active links
|
||||||
|
but reuse no completed resolution. A finite contained graph `s00 -> .`,
|
||||||
|
`s01 -> s00/s00`, `s02 -> s01/s01`, and so on repeats completed dependencies.
|
||||||
|
Its work follows T(N) = 2T(N-1) + O(1), despite linear graph size. Directory
|
||||||
|
selection resolves discovered links before deciding on recursive descent, so
|
||||||
|
`recursive: false` does not avoid it. Existing source/entry/node/import-depth
|
||||||
|
budgets do not account for the repeated expansion.
|
||||||
|
|
||||||
|
A real-link fixture exposed through a native `fs.ReadLinkFS` wrapper recorded
|
||||||
|
32, 128, 512, 2,048, and 8,192 `Lstat` calls for 3, 5, 7, 9, and 11 entries;
|
||||||
|
corresponding `ReadLink` counts were 11, 57, 247, 1,013, and 4,083. Thirteen
|
||||||
|
entries reached a diagnostic ceiling of 12,000 `Lstat` calls. With following
|
||||||
|
disabled, that fixture used 17 `Lstat` calls and no `ReadLink`. The host resolver's
|
||||||
|
matching logic was reviewed, not separately operation-count tested.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Reuse fully resolved targets within the
|
||||||
|
resolution operation, keyed for the rooted namespace and full link path, or use
|
||||||
|
equivalent dependency evaluation. Retain active-path cycle detection, containment,
|
||||||
|
finite reuse, relative/absolute host semantics, and the stable-input assumption.
|
||||||
|
A global visited-means-cycle rule would incorrectly reject valid inputs. Cover
|
||||||
|
both resolvers and directory/glob entry points with small fixtures and bounded
|
||||||
|
operation observation, not timing thresholds or exhaustion tests. No arbitrary
|
||||||
|
link cap, public cache, or default-follow change is selected.
|
||||||
|
|
||||||
|
The availability risk requires influence over imported filesystem data; it is
|
||||||
|
not an anonymous remote vulnerability by itself. No concurrent mutation or
|
||||||
|
outside-root target is needed, and accidental graphs can cause the same work.
|
||||||
|
|
||||||
|
### C03. Separate Glob Entry Preparation From Traversal
|
||||||
|
|
||||||
|
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
|
||||||
|
belong to [future work](future.md#1-separate-glob-entry-preparation-from-traversal),
|
||||||
|
for consideration after this roadmap's selected work is complete.
|
||||||
|
|
||||||
|
## D. Retained History And Unused Evidence
|
||||||
|
|
||||||
|
### D01. Account For The Complete Published History Graph
|
||||||
|
|
||||||
|
Type: reported defect. Priority: medium. Source: resources 2; testing 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** The checkpoint in [load.go](../../internal/engine/load.go)
|
||||||
|
and [history.go](../../internal/engine/history.go) counts a different root set
|
||||||
|
from [load_result.go](../../internal/engine/load_result.go) publication. Separately
|
||||||
|
cloned post-CLI input, `structuralInput`, `preCLI`, and later inheritance copies
|
||||||
|
are not all represented. A read-only diagnostic of a small successful load with
|
||||||
|
a no-op resolver counted 14 distinct nodes before including the two published
|
||||||
|
replay roots and 22 with them. Source inspection found that the checkpoint uses
|
||||||
|
a different, same-sized decoded-final instance and also counts 14 in this case.
|
||||||
|
Generalized to N scalar items, the reported counts were 3N + 8 versus 5N + 12;
|
||||||
|
800,000 items would pass the checkpoint yet exceed the existing 4,000,000-node
|
||||||
|
history allowance. That large case was derived, not allocated or executed.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Use pointer-aware accounting for every document
|
||||||
|
root actually retained at publication, including replay and copied inheritance
|
||||||
|
history, counting shared nodes once. Where practical, reserve/charge copies before
|
||||||
|
allocation. Coordinate removal of unused trees under D03 and recalculate the
|
||||||
|
retained set afterward; that removal alone does not establish complete accounting.
|
||||||
|
Reuse already-owned immutable snapshots only where equivalence and ownership
|
||||||
|
are demonstrated. This need not redesign public metadata copying.
|
||||||
|
|
||||||
|
Use a small controlled allowance to test publication integration and the existing
|
||||||
|
`explain.limit_exceeded` outcome, as well as count-once sharing. Helper-only tests
|
||||||
|
cannot detect roots omitted by callers. Preserve [historical
|
||||||
|
inspection](../config.md#historical-value-explanation), detached ownership, and
|
||||||
|
the existing quota; no smaller limit or universal heap-size bound is proposed.
|
||||||
|
|
||||||
|
### D02. Remove The Unconsumed Override-Result Pre-CLI Copy
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Source: deadcode 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `decodedOverrideResult.preCLI` in
|
||||||
|
[override_decode.go](../../internal/engine/override_decode.go) clones the entire
|
||||||
|
decoded final tree even without CLI assignments. Its readers are private tests;
|
||||||
|
production independently clones `before.final` into the active context in
|
||||||
|
[override_context.go](../../internal/engine/override_context.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove that result member, its clone, and
|
||||||
|
assertions solely about it. Preserve the independently owned pre-CLI context
|
||||||
|
that actually feeds resolution, publication, and explanation. Retain behavioral
|
||||||
|
CLI/history/ownership coverage. The change removes a redundant traversal, not
|
||||||
|
the active replay snapshot or its detachment boundary.
|
||||||
|
|
||||||
|
### D03. Remove The Unqueried Published Structural-Input Copy
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Source: deadcode 2.
|
||||||
|
|
||||||
|
**Problem and evidence.** `Inspection.structuralInput` in
|
||||||
|
[load.go](../../internal/engine/load.go) is cloned and indexed at publication,
|
||||||
|
but has no production reader. [explain.go](../../internal/engine/explain.go)
|
||||||
|
uses source/inheritance replay, `preCLI`, and `input`. The immutable lookup is
|
||||||
|
addressed by supplied node pointers; indexing this otherwise unused root does
|
||||||
|
not make it useful and keeps its mappings alive.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove the inspection member, final clone,
|
||||||
|
and its `retainedTrees` inclusion. Keep earlier `resolved.structuralInput` uses
|
||||||
|
for path finalization and sensitivity. Check explanation, historical paths,
|
||||||
|
redaction, and ownership through active outputs. Coordinate D01 for all remaining
|
||||||
|
retained roots; do not infer that structural evidence is globally unused.
|
||||||
|
|
||||||
|
### D04. Stop Constructing Unconsumed Combination Evidence
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Source: deadcode 5.
|
||||||
|
|
||||||
|
**Problem and evidence.** `sequenceCombinationResult.discardedLower` in
|
||||||
|
[sequence_combine.go](../../internal/engine/sequence_combine.go) allocates a list
|
||||||
|
of all replaced lower indexes with no production reader. Three tests inspect it.
|
||||||
|
`inheritanceReplacement.path`, `previous`, and `effective` in
|
||||||
|
[inheritance.go](../../internal/engine/inheritance.go) are also unread, but their
|
||||||
|
writers perform path/origin copies and displaced-subtree walks. Active inheritance
|
||||||
|
history uses operations and projected changes elsewhere. Replacement `before`
|
||||||
|
and `after` trees still feed node accounting.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove only unread members and work solely
|
||||||
|
feeding them. Keep replacement trees, item projections, replacement mappings,
|
||||||
|
`appendedHigher`, active provenance/history, and node accounting. Keep
|
||||||
|
`sequenceIndexes` for append and `displacedOrigins` for overlay callers. Replace
|
||||||
|
private unused-evidence assertions with existing behavior-level replacement,
|
||||||
|
removed-descendant, and history protection where necessary. Coordinate D01 and
|
||||||
|
E03 without deleting active evidence merely because another representation exists.
|
||||||
|
|
||||||
|
## E. Collection Processing And Efficiency
|
||||||
|
|
||||||
|
All complexity estimates here describe identified internal work with bounded-
|
||||||
|
depth examples and expected hash lookup costs. Actual path processing, copied
|
||||||
|
payload, and expanded output remain costs; no end-to-end complexity or timing
|
||||||
|
guarantee is proposed.
|
||||||
|
|
||||||
|
### E01. Avoid Scanning Every Policy For Each Replacement
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 1.
|
||||||
|
|
||||||
|
**Problem and evidence.** `retireCollectionPoliciesAtOrBelow` in
|
||||||
|
[collection_policy_state.go](../../internal/engine/collection_policy_state.go)
|
||||||
|
scans all P policies for each of Q ordinary replacements in
|
||||||
|
[overlay.go](../../internal/engine/overlay.go), even if the changed scalars are
|
||||||
|
unrelated to every collection. P = Q = N gives O(N²) visits for linear-sized
|
||||||
|
input and output within one tier.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider an operation-local semantic-path
|
||||||
|
index with descendant queries, maintained as policies change, or batch retirement
|
||||||
|
where timing remains equivalent. The disjoint case can approach O(P + Q), with
|
||||||
|
additional work charged to affected entries/path lengths. Preserve explicit-mode
|
||||||
|
persistence, record ownership, and the moment old identity retires; delayed
|
||||||
|
retirement must not clear newly established higher-tier policy. Evaluate existing
|
||||||
|
inheritance indexing without requiring identical interfaces. Retain a distinct
|
||||||
|
disjoint-replacement case even if implementation is coordinated with E02/E03.
|
||||||
|
|
||||||
|
### E02. Avoid Recopying Unrelated Policy Across Small Tiers
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 2.
|
||||||
|
|
||||||
|
**Problem and evidence.** `lowerCollectionPoliciesForClaims` in
|
||||||
|
[collection_retirement.go](../../internal/engine/collection_retirement.go) clones
|
||||||
|
and scans lower policy, and `resolveStrictCollectionPolicies` in
|
||||||
|
[compose.go](../../internal/engine/compose.go) clones it again. Overlay preparation
|
||||||
|
repeats full-state work and checks retained destinations. P initial policies
|
||||||
|
followed by T scalar-only tiers cost O(T × P) work/allocation although only O(P + T)
|
||||||
|
material exists. Copies include record order and nested record policy; these
|
||||||
|
are transient private copies, not independently published snapshots.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Separate read-only lower-policy lookup from
|
||||||
|
owned current-tier changes, localize work to incoming claims/declarations, and
|
||||||
|
apply changes to the assembly owner. Keep copies at actual mutable ownership
|
||||||
|
boundaries. Removing the immediate duplicate copy helps but does not alone fix
|
||||||
|
O(T × P) growth. Preserve pending declarations, replacement eligibility, and
|
||||||
|
independent nested record policies. This case remains distinct from E01 and the
|
||||||
|
deferred public metadata-copy experiment.
|
||||||
|
|
||||||
|
### E03. Clarify Policy Preparation And Merge Evidence Application
|
||||||
|
|
||||||
|
Type: selected refactor. Priority: medium. Source: complexity 3.
|
||||||
|
|
||||||
|
**Problem and evidence.** [overlay.go](../../internal/engine/overlay.go) combines
|
||||||
|
claim/declaration preparation, operation resolution, callbacks, diagnostics,
|
||||||
|
origins/history, retirement, record policy rebuilding, and declaration-only
|
||||||
|
finalization. Overlay and [inheritance](../../internal/engine/inheritance_collection.go)
|
||||||
|
separately translate sequence results into history descriptors and reconstruct
|
||||||
|
record order. `combineSequence` already owns shared value combination.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider explicit preparation, application,
|
||||||
|
and finalization operations with a small result/owner for evidence and diagnostics.
|
||||||
|
Share straightforward history-descriptor construction; assess projected-record
|
||||||
|
selection sharing while keeping precedence and inheritance policy ownership
|
||||||
|
explicit. Do not unify distinct origin or mode-lifetime rules behind a generic
|
||||||
|
strategy framework. Coordinate E01/E02 so extraction does not preserve avoidable
|
||||||
|
full-table work. Preserve pending declarations, replacement ordering, retained
|
||||||
|
versus incoming record owners, and stable history. D04 can remove unused evidence
|
||||||
|
without requiring this broader refactor.
|
||||||
|
|
||||||
|
### E04. Index Tier-History Projection Lookups
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 3.
|
||||||
|
|
||||||
|
**Problem and evidence.** `projectTierHistoryApplications` in
|
||||||
|
[sequence_history_projection.go](../../internal/engine/sequence_history_projection.go)
|
||||||
|
calls unindexed `documentAtPath` for each mapped sequence. N sibling collections
|
||||||
|
selected by one wildcard identity registration incur O(N²) mapping comparisons
|
||||||
|
while projecting linear-sized source/history material.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Lazily prepare one read-only `documentLookup`
|
||||||
|
per original application value with mapped contributions, or collect needed nodes
|
||||||
|
in one traversal. With V nodes, S contributions, depth D, and I projected items,
|
||||||
|
that portion can be O(V + S × D + I). Preserve authored coordinates, sparse item
|
||||||
|
projections, replacement evidence, and independent output storage. This is load-
|
||||||
|
time coordinate projection, not branch-local explanation replay.
|
||||||
|
|
||||||
|
### E05. Restore Compound Identity Without Repeated Mapping Scans
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: medium. Source: efficiency 4.
|
||||||
|
|
||||||
|
**Problem and evidence.** `preserveInheritanceIdentityFields` in
|
||||||
|
[inheritance_scope.go](../../internal/engine/inheritance_scope.go) searches child
|
||||||
|
and merged mappings for each identity field. K fields and C/M child/merged
|
||||||
|
entries cost O(K × (C + M)) comparisons plus required clones. A wide compound
|
||||||
|
identity can make this quadratic without expanding record output.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Index merged positions and walk child entries
|
||||||
|
against an identity-field set, or use equivalent local mapping indexes, for
|
||||||
|
O(K + C + M) lookup work plus copies. Preserve child-owned identity values and
|
||||||
|
locations, mapping order, and detached nodes. Do not change identity equality or
|
||||||
|
parent selection. Removing the restoration step instead requires a separate
|
||||||
|
equivalence review; apparent duplication alone is insufficient.
|
||||||
|
|
||||||
|
## F. Remaining Maintenance And Test Improvements
|
||||||
|
|
||||||
|
### F01. Remove The Test-Only Eager Acquisition Mode
|
||||||
|
|
||||||
|
Type: cleanup. Priority: medium. Sources: complexity 1, deadcode 8.
|
||||||
|
|
||||||
|
**Problem and evidence.** Both production constructors of the request declared in
|
||||||
|
[acquisition.go](../../internal/engine/acquisition.go) set
|
||||||
|
`inputAcquisitionRequest.deferAssembly` to true. Only private
|
||||||
|
test helpers select the eager composition branch in
|
||||||
|
[import_acquisition.go](../../internal/engine/import_acquisition.go). Acquisition
|
||||||
|
maintains two return protocols and tests can bypass the production phase handoff.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Return retained scopes consistently and remove
|
||||||
|
the unused production mode. Tests needing composition should invoke the active
|
||||||
|
composition phase through a small test-only helper; acquisition-only tests should
|
||||||
|
remain acquisition-only. Preserve closure, pending-item reservation, accounting,
|
||||||
|
diagnostics, and meaningful composition coverage. This does not retire public
|
||||||
|
loading workflows or imply that every nil-state/directive context is unused.
|
||||||
|
|
||||||
|
### F02. Clarify Active Acquisition And Coordinator Handoffs
|
||||||
|
|
||||||
|
Type: selected refactor. Priority: medium. Source: complexity 2.
|
||||||
|
|
||||||
|
**Problem and evidence.** `acquireInput` in
|
||||||
|
[acquisition.go](../../internal/engine/acquisition.go) combines discovery, parsing,
|
||||||
|
directive/version branches, placement/accounting, dispatch, and passive contexts.
|
||||||
|
Version branches repeat source-ID/failure/result handling, and layered processing
|
||||||
|
extracts and restores directives. [load.go](../../internal/engine/load.go) contains
|
||||||
|
a substantial ordinary-load context fallback; [load_request.go](../../internal/engine/load_request.go)
|
||||||
|
repeats tier declaration/application/projection handoffs. Parsed, local, composed,
|
||||||
|
inherited, and version-path representations are difficult to follow together.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider cohesive parsed-input directive/version
|
||||||
|
processing and standalone placement/registration operations, plus explicit context,
|
||||||
|
structural-expansion, and repeated tier handoffs. Reuse existing state/results;
|
||||||
|
helpers that merely share the same mutable surrounding locals do not resolve
|
||||||
|
ownership. Preserve visible phase order, ordinary/layered overlay differences,
|
||||||
|
default/enabled version timing, warnings, exactly-once callbacks, and load-wide
|
||||||
|
versus tier-local state. F01 removes a different, inactive protocol; neither task
|
||||||
|
requires a new context hierarchy or public request model.
|
||||||
|
|
||||||
|
### F03. Retire The Parallel Test-Only Import Directive Parser
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 7.
|
||||||
|
|
||||||
|
**Problem and evidence.** `parseImportSpecificationDirective` in
|
||||||
|
[import_spec.go](../../internal/engine/import_spec.go) is called only by the
|
||||||
|
`parseImportSpecsYAML` test fixture. It duplicates mapping/field dispatch already
|
||||||
|
owned by `parseRootDirectiveBlock` in [directives.go](../../internal/engine/directives.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Use the active parser with the appropriate
|
||||||
|
context, or parse the declaration list directly when that is the tested boundary,
|
||||||
|
then remove the obsolete implementation. Preserve malformed-declaration and
|
||||||
|
selector coverage. Keep the active imports-only context used in layered version
|
||||||
|
processing; thin test helpers delegating to production are not inherently dead.
|
||||||
|
|
||||||
|
### F04. Remove The Disconnected Root Record-Clone Family
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 3; related testing 6.
|
||||||
|
|
||||||
|
**Problem and evidence.** The private family in root [records.go](../../records.go)
|
||||||
|
has no production facade entry: `cloneLocation`, `cloneLocations`, `cloneSourceInfo`,
|
||||||
|
`cloneImportSelections`, `cloneSourceVersionCheck`, `cloneSourceInfos`, `cloneOrigin`,
|
||||||
|
`cloneFieldInfo`, `cloneChange`, `cloneDiagnostic`, and `cloneDiagnostics`.
|
||||||
|
Helpers refer to each other; only tests call `cloneSourceInfo`. Active root-owned
|
||||||
|
records come through [conversion.go](../../conversion.go).
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Delete the disconnected family and tests solely
|
||||||
|
protecting it. Keep conversion assertions and useful returned-record ownership
|
||||||
|
coverage through repeated public queries and mutation of mutable nested results.
|
||||||
|
Replacing an entire `Destination` field on a copied struct does not demonstrate
|
||||||
|
nested detachment. Keep active engine clones and `Path.clone`; do not expose
|
||||||
|
opaque path internals or invent a stronger copy guarantee. Coordinate F12.
|
||||||
|
|
||||||
|
### F05. Remove Uncalled Engine Helpers
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 4; placement subset also complexity 5.
|
||||||
|
|
||||||
|
The type-aware audit found no callers or function-value uses in the inspected
|
||||||
|
production/test build configurations for the following helpers:
|
||||||
|
|
||||||
|
| Candidate | Active counterpart to preserve |
|
||||||
|
| --- | --- |
|
||||||
|
| `mergeDisjointMappings` in [compose.go](../../internal/engine/compose.go) | `mergeDisjointMappingsAtPath` and shared recursive merging; the old helper references only itself. |
|
||||||
|
| `mergeHistoryDocument` in [history.go](../../internal/engine/history.go) | `mergeHistoryDocumentAtPath` with sequence evidence. |
|
||||||
|
| `inheritanceDirectiveLocation` in [inheritance.go](../../internal/engine/inheritance.go) | Preparation's directive entry/location handling. |
|
||||||
|
| `addSecretEvidencePath` in [secret.go](../../internal/engine/secret.go) | Projected version sensitivity evidence and the active path accumulator. |
|
||||||
|
| `PlacementModeFromFacade`, `PlacementModeForFacade` in [destination.go](../../internal/engine/destination.go) | Input/options conversion and `SourceInfoPlacementModeForFacade`. |
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Remove these definitions and imports/comments
|
||||||
|
solely supporting them after confirming references at implementation time. Preserve
|
||||||
|
active counterparts and public root APIs. The placement pair can be removed
|
||||||
|
without selecting the broader transport work in F07.
|
||||||
|
|
||||||
|
### F06. Trim Unread Private Result And Transport Members
|
||||||
|
|
||||||
|
Type: cleanup. Priority: low. Source: deadcode 6.
|
||||||
|
|
||||||
|
The audit identified the following populated members without production readers.
|
||||||
|
Some private tests inspect them. Remove only these slots and computations solely
|
||||||
|
feeding them, preserving validation and active outputs:
|
||||||
|
|
||||||
|
| Owner and candidate members | Boundary to preserve |
|
||||||
|
| --- | --- |
|
||||||
|
| `collectionPolicyResolution.declarations`, `declarationsPresent`, `identityDeclarationPresent`, `applicationIdentityFields` in [collection_policy.go](../../internal/engine/collection_policy.go) | Local declaration normalization, `modeDeclarationPresent`, and `applicationIdentityPresent`. |
|
||||||
|
| `inputPresence.null` in [decode.go](../../internal/engine/decode.go) | Presence/location and effective-node null reporting. |
|
||||||
|
| `rootDirectiveExtraction.importsPresent`, `sequencesPresent` in [directives.go](../../internal/engine/directives.go) | Parsed declarations, diagnostics, and directive validation. |
|
||||||
|
| `importSelection.outcome` in [import_selection.go](../../internal/engine/import_selection.go) and glob selection | Candidate/admission behavior and the distinct, active public `importSelectionEvidence.outcome`. |
|
||||||
|
| `importDeclarationSpec.excludeSet` in [import_spec.go](../../internal/engine/import_spec.go) | Active `exclude` policy. |
|
||||||
|
| `schemaPath.field`, `dynamic` in [schema.go](../../internal/engine/schema.go) | Descriptor and success result used by path consumers; coordinate A01. |
|
||||||
|
| `selectedOverlayRequest.filesystem` in [selected_overlay.go](../../internal/engine/selected_overlay.go) | Explicit filesystem-access argument and borrowed ownership. |
|
||||||
|
| `sequenceDeclarationSpec.destinationLocation` in [sequence_declaration.go](../../internal/engine/sequence_declaration.go) | Other active declaration/field diagnostic locations. |
|
||||||
|
| `sourceVersionEvidence.absence`, `checked`; `sourceVersionAncestor.kind` in [source_version.go](../../internal/engine/source_version.go) | Actual checks, version/presence/location evidence, ancestor paths, and sensitivity. |
|
||||||
|
| `postDecodeOverrideContext.sources` in [override_context.go](../../internal/engine/override_context.go) | Local source list used to build callback resolution input. |
|
||||||
|
|
||||||
|
**Acceptance.** Preserve public presence, null, selection, version, diagnostic,
|
||||||
|
and ownership behavior. Rework assertions on inactive annotations only where
|
||||||
|
they protect a meaningful supported outcome. A lack of selector reads alone is
|
||||||
|
not a general removal rule: map keys, embedded records, and interfaces require
|
||||||
|
separate analysis.
|
||||||
|
|
||||||
|
### F07. Simplify Redundant Facade-To-Engine Transport
|
||||||
|
|
||||||
|
Deferred under OQ-3. The evidence, proposed scope, and remaining questions now
|
||||||
|
belong to [future work](future.md#2-simplify-redundant-facade-to-engine-transport),
|
||||||
|
for consideration after this roadmap's selected work is complete. F05's bounded
|
||||||
|
removal of unused placement bridges remains selected.
|
||||||
|
|
||||||
|
### F08. Index Secret-Failure Location Queries Lazily
|
||||||
|
|
||||||
|
Type: optimization candidate. Priority: low. Source: efficiency 5.
|
||||||
|
|
||||||
|
**Problem and evidence.** `secretDiagnostic` in
|
||||||
|
[secret_reference.go](../../internal/engine/secret_reference.go) calls unindexed
|
||||||
|
nearest-location traversal for each failed reference. N missing references in a
|
||||||
|
wide mapping incur O(N²) comparisons, even when they use one cached environment
|
||||||
|
name. Normal deterministic traversal/sorting does not require these scans.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Consider a lazy read-only `documentLookup`
|
||||||
|
shared during secret resolution, or carry relevant location state through the
|
||||||
|
walk. With V nodes, F failures, and depth D, attribution can be O(V + F × D),
|
||||||
|
apart from sorting. Keep the successful path free of an unnecessary eager index.
|
||||||
|
Preserve all diagnostics, ancestor fallback, source coordinates, and safe messages;
|
||||||
|
failing fast would change behavior rather than optimize it.
|
||||||
|
|
||||||
|
### F09. Cover Public Historical-Path Enumeration
|
||||||
|
|
||||||
|
Type: test improvement. Priority: medium. Source: testing 2; related testing 6.
|
||||||
|
|
||||||
|
**Problem and evidence.** Root `Metadata.HistoricalPaths` in
|
||||||
|
[metadata.go](../../metadata.go) had no executed statements in the aggregate
|
||||||
|
profile. Engine tests cover it thoroughly, but not root path conversion. An empty
|
||||||
|
or incorrectly converted public result could leave engine tests green.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Add one external-package test enumerating
|
||||||
|
effective and removed paths, using returned paths with `Lookup` and `History`,
|
||||||
|
including a numeric-key/sequence shape change. Cover sorted unique pointer
|
||||||
|
spelling, zero metadata, and replacement of a returned slice element without
|
||||||
|
affecting later queries. Keep detailed path mutation/shape tests in the engine.
|
||||||
|
This can replace F12's private segment-representation assertion without copying
|
||||||
|
the complete internal matrix or exposing private segments.
|
||||||
|
|
||||||
|
### F10. Strengthen Fuzz Detachment Assertions
|
||||||
|
|
||||||
|
Type: test improvement. Priority: low. Source: testing 4.
|
||||||
|
|
||||||
|
**Problem and evidence.** `FuzzCompositionAndOverlay` in
|
||||||
|
[composition_fuzz_test.go](../../internal/engine/composition_fuzz_test.go) appends
|
||||||
|
to the result root then compares inputs. A shallow root copy sharing existing
|
||||||
|
descendants can pass because append changes neither the input's slice header nor
|
||||||
|
elements within its old length. Existing deterministic merge tests already
|
||||||
|
exercise meaningful nested mutation.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Mutate an existing result descendant or
|
||||||
|
in-length element within the bounded fuzz target, then compare both inputs.
|
||||||
|
Handle empty containers separately and retain input immutability/commutativity
|
||||||
|
properties. Improve the existing assertion rather than adding another ownership
|
||||||
|
matrix or claiming all ownership coverage is absent.
|
||||||
|
|
||||||
|
### F11. Replace Exact Stat-Call Choreography With Observable Ordering
|
||||||
|
|
||||||
|
Type: test cleanup. Priority: low. Source: testing 5.
|
||||||
|
|
||||||
|
**Problem and evidence.** `TestSelectNamedImportsChecksNormalizedPathsLexically`
|
||||||
|
in [import_selection_test.go](../../internal/engine/import_selection_test.go)
|
||||||
|
requires `a.yaml,a.yaml,z.yaml,z.yaml` stat calls but checks candidate count only.
|
||||||
|
Lexical handling matters; exactly two stats per file is not an established contract.
|
||||||
|
|
||||||
|
**Recommendation and acceptance.** Assert ordered candidate paths and, where
|
||||||
|
needed, the first diagnostic site using failing targets. Consolidate with existing
|
||||||
|
selection tests and delete the standalone case if its protection is subsumed.
|
||||||
|
Keep meaningful observations such as not opening excluded contents. Do not turn
|
||||||
|
incidental helper call counts into an efficiency or behavior guarantee.
|
||||||
|
|
||||||
|
### F12. Consolidate Facade Scaffolding Around Public Behavior
|
||||||
|
|
||||||
|
Type: test cleanup. Priority: low. Source: testing 6; related deadcode 3.
|
||||||
|
|
||||||
|
**Evidence and recommendations.**
|
||||||
|
|
||||||
|
- Remove the duplicate six-type ownership loop in
|
||||||
|
[facade_compatibility_test.go](../../facade_compatibility_test.go), retaining the
|
||||||
|
comprehensive owner in [facade_candidate_test.go](../../facade_candidate_test.go),
|
||||||
|
compile-time interfaces, public construction, and distinct lifecycle checks.
|
||||||
|
A literal assignment equality assertion on a new `Result.Config` adds no
|
||||||
|
protection beyond compilation and ordinary Go assignment.
|
||||||
|
- Replace the former `TestWrappedPathsRetainEngineSegmentIntent` internal
|
||||||
|
representation check with public contextual mapping/index behavior under F09.
|
||||||
|
Keep engine segment-intent coverage; public source-form behavior is consolidated
|
||||||
|
in [facade_equivalence_test.go](../../facade_equivalence_test.go).
|
||||||
|
- Move `TestWrappedSourcesPreserveEngineOwnership` from direct `engine.Load`
|
||||||
|
through `source.toEngine()` to public `Load` and root error assertions.
|
||||||
|
Consolidate reader cases with [facade_equivalence_test.go](../../facade_equivalence_test.go)
|
||||||
|
while preserving explicit copied-reader and byte-source mutation coverage.
|
||||||
|
- F04 owns removal of the disconnected clone family and the ineffective whole-
|
||||||
|
destination assignment assertion in [records_test.go](../../records_test.go).
|
||||||
|
Keep useful conversion and returned-record ownership protection there or in
|
||||||
|
existing public source-snapshot tests.
|
||||||
|
|
||||||
|
**Acceptance.** Remove scaffolding only after preserving its distinct behavioral
|
||||||
|
risks. Root integration tests still protect conversion, error typing, callbacks,
|
||||||
|
source identity, disclosure, and ownership; engine coverage does not substitute
|
||||||
|
for them. Static public/dependency-boundary tests remain useful.
|
||||||
|
|
||||||
|
## Boundaries To Preserve
|
||||||
|
|
||||||
|
The audits found the architecture coherent and did not establish a need for new
|
||||||
|
public features, a replacement loading API, package proliferation, or a general
|
||||||
|
visitor/strategy framework. Root-owned conversions, synchronous load-local state,
|
||||||
|
shared dependency scheduling, indexed combination, separated inheritance phases,
|
||||||
|
and passive resource contexts remain useful owners. The clone traversal was
|
||||||
|
already reviewed and retained at `34cbc0d:docs/roadmap/consolidation.md`; a high
|
||||||
|
complexity estimate does not reopen it.
|
||||||
|
|
||||||
|
No additional supported-use data race, deadlock, background-work leak, credential
|
||||||
|
disclosure, command injection, authority bypass, or host containment escape was
|
||||||
|
established. These are limited audit observations, not absence guarantees.
|
||||||
|
Caller-owned mutable objects, blocking operations, safe callback messages, and
|
||||||
|
borrowed filesystem behavior retain their documented responsibilities. No new
|
||||||
|
locking, cancellation, retry, authentication, permission, or environment-variable
|
||||||
|
restriction follows from these findings.
|
||||||
|
|
||||||
|
Preserve raw-error suppression, structured safe diagnostics, original application
|
||||||
|
panic propagation, intentional bootstrap partial writes, allowed partial writer
|
||||||
|
output, and documented read-only regular-file close handling. Regression tests
|
||||||
|
must enforce existing contracts rather than broaden them. Optional absence must
|
||||||
|
remain distinct from acquisition failure, and genuine finite reuse from cycles.
|
||||||
|
|
||||||
|
Public APIs are not dead merely because repository examples omit them. Interface
|
||||||
|
dispatch, reflection, build constraints, identity tokens, map-key equality, and
|
||||||
|
uninstantiated generics require more than caller counts. In particular,
|
||||||
|
`decodeInlineMapEntry` and import-cycle diagnostics remain active.
|
||||||
|
`Inspection.resolution` retains recorded snapshots under the existing resolution
|
||||||
|
contract even though replay uses a coalesced view; it is not a cleanup candidate.
|
||||||
|
|
||||||
|
Selected stable Kahn ordering, deterministic sorting, exact numeric identity,
|
||||||
|
and the expressly selected registration-overlap check are not reconsidered here.
|
||||||
|
Required detached copies and genuinely expanding output remain legitimate costs.
|
||||||
|
The broader replay, metadata-handoff, cross-load schema-cache, and contributor-
|
||||||
|
trace experiments stay in [future.md](future.md#5-performance-experiments).
|
||||||
|
Local fixes and removal of proven unused work do not select those experiments.
|
||||||
|
|
||||||
|
## Validation Approach For Selected Work
|
||||||
|
|
||||||
|
Use the existing [development guide](../development.md#baseline-validation) and
|
||||||
|
[testing policy](../policy/testing.md). Add meaningful regressions with the
|
||||||
|
selected fixes, at the narrowest stable boundary that detects the reported
|
||||||
|
failure. Extend existing owners rather than duplicating internal matrices at
|
||||||
|
every layer. Assert nil results and safe structured failures where relevant,
|
||||||
|
and verify retained state/handles independently when they have distinct owners.
|
||||||
|
|
||||||
|
Keep termination, allocation, and link-expansion probes bounded. Use small
|
||||||
|
controlled budgets, disposable processes where necessary, and filesystem
|
||||||
|
observation where it detects the specific defect. No wall-clock thresholds,
|
||||||
|
coverage percentages, complexity-score limits, exact incidental syscall traces,
|
||||||
|
new resource caps, or extra release gates are selected. Algorithmic evidence is
|
||||||
|
not a measured speedup. Performance experiments need separate scope and suitable
|
||||||
|
measurement conditions; no benchmarks were run for this consolidation.
|
||||||
|
|
||||||
|
Once selected work changes behavior or structure, update its canonical contract
|
||||||
|
or internal owner as appropriate. This review document remains the finding/status
|
||||||
|
owner until an implementation plan is prepared; completed records can retire
|
||||||
|
to Git history under the normal documentation lifecycle.
|
||||||
|
|
||||||
|
## Original-Finding Map
|
||||||
|
|
||||||
|
Original files are retrieved from `ecd5d5d:docs/roadmap/<filename>`. Every numbered
|
||||||
|
finding is mapped below; repeated citations are corroboration or shared coverage,
|
||||||
|
not extra fixes.
|
||||||
|
|
||||||
|
| Original finding | Consolidated owner |
|
||||||
|
| --- | --- |
|
||||||
|
| correctness 1 | [A01](#a01-terminate-pointer-only-recursive-descriptor-traversal) |
|
||||||
|
| correctness 2 | [A02](#a02-make-failed-dynamic-descriptor-construction-transactional) |
|
||||||
|
| correctness 3 | [A03](#a03-distinguish-acyclic-slice-views-in-cycle-detection) |
|
||||||
|
| correctness 4 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding) |
|
||||||
|
| efficiency 1 | [E01](#e01-avoid-scanning-every-policy-for-each-replacement) |
|
||||||
|
| efficiency 2 | [E02](#e02-avoid-recopying-unrelated-policy-across-small-tiers) |
|
||||||
|
| efficiency 3 | [E04](#e04-index-tier-history-projection-lookups) |
|
||||||
|
| efficiency 4 | [E05](#e05-restore-compound-identity-without-repeated-mapping-scans) |
|
||||||
|
| efficiency 5 | [F08](#f08-index-secret-failure-location-queries-lazily) |
|
||||||
|
| efficiency 6 | [A05](#a05-reuse-successful-dynamic-type-eligibility-checks-locally) |
|
||||||
|
| complexity 1 | [F01](#f01-remove-the-test-only-eager-acquisition-mode) |
|
||||||
|
| complexity 2 | [F02](#f02-clarify-active-acquisition-and-coordinator-handoffs) |
|
||||||
|
| complexity 3 | [E03](#e03-clarify-policy-preparation-and-merge-evidence-application) |
|
||||||
|
| complexity 4 | [C03](#c03-separate-glob-entry-preparation-from-traversal) |
|
||||||
|
| complexity 5 | [F07](#f07-simplify-redundant-facade-to-engine-transport), with bounded bridge removal in [F05](#f05-remove-uncalled-engine-helpers) |
|
||||||
|
| concurrency 1 | [B01](#b01-deactivate-callback-contexts-during-panic-unwinding), corroboration |
|
||||||
|
| resources 1 | [A04](#a04-enforce-existing-node-budgets-before-container-reservations) |
|
||||||
|
| resources 2 | [D01](#d01-account-for-the-complete-published-history-graph) |
|
||||||
|
| resources 3 | [B02](#b02-close-the-owned-import-root-when-a-version-callback-panics) |
|
||||||
|
| error-handling 1 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests) |
|
||||||
|
| security 1 | [C02](#c02-avoid-reexpanding-completed-finite-symlink-dependencies) |
|
||||||
|
| testing 1 | Regression acceptance under A01–A04, B01–B02, C02, and D01 |
|
||||||
|
| testing 2 | [F09](#f09-cover-public-historical-path-enumeration) |
|
||||||
|
| testing 3 | [C01](#c01-preserve-directory-failure-phase-and-strengthen-its-tests), including shared fixture assertions |
|
||||||
|
| testing 4 | [F10](#f10-strengthen-fuzz-detachment-assertions) |
|
||||||
|
| testing 5 | [F11](#f11-replace-exact-stat-call-choreography-with-observable-ordering) |
|
||||||
|
| testing 6 | [F12](#f12-consolidate-facade-scaffolding-around-public-behavior), with F04 and F09 |
|
||||||
|
| deadcode 1 | [D02](#d02-remove-the-unconsumed-override-result-pre-cli-copy) |
|
||||||
|
| deadcode 2 | [D03](#d03-remove-the-unqueried-published-structural-input-copy) |
|
||||||
|
| deadcode 3 | [F04](#f04-remove-the-disconnected-root-record-clone-family) |
|
||||||
|
| deadcode 4 | [F05](#f05-remove-uncalled-engine-helpers) |
|
||||||
|
| deadcode 5 | [D04](#d04-stop-constructing-unconsumed-combination-evidence) |
|
||||||
|
| deadcode 6 | [F06](#f06-trim-unread-private-result-and-transport-members) |
|
||||||
|
| deadcode 7 | [F03](#f03-retire-the-parallel-test-only-import-directive-parser) |
|
||||||
|
| deadcode 8 | [F01](#f01-remove-the-test-only-eager-acquisition-mode), duplicate of complexity 1 |
|
||||||
|
|
||||||
|
## Settled Scope Decisions
|
||||||
|
|
||||||
|
### OQ-1. What Should The First Implementation Scope Include?
|
||||||
|
|
||||||
|
**Settled:** include all nine reported defects with their regression coverage,
|
||||||
|
the bounded cleanup, and the identified independent test improvements.
|
||||||
|
|
||||||
|
| Selected work | Finding IDs |
|
||||||
|
| --- | --- |
|
||||||
|
| Reported defects and accompanying regression coverage | A01–A04, B01–B02, C01–C02, D01 |
|
||||||
|
| Removal of unused copies, evidence, helpers, fields, and test-only production paths | D02–D04, F01, F03–F06 |
|
||||||
|
| Public coverage and stronger or less redundant test assertions | F09–F12 |
|
||||||
|
|
||||||
|
This selects 21 review entries. Reconfirm cleanup evidence on the implementation
|
||||||
|
baseline before removal, and preserve distinct behavioral protection when
|
||||||
|
replacing tests. Coordinate unused history copies with D01's retained-graph
|
||||||
|
accounting; verify callback-state closure and host-root closure independently.
|
||||||
|
Keep fixes and cleanup in focused, reviewable commits, even if delivered in one
|
||||||
|
sprint. Cleanup must not delay a consequential fix.
|
||||||
|
|
||||||
|
The combined scope restores existing contracts and removes demonstrated
|
||||||
|
maintenance burden while avoiding repeated work at the same ownership and test
|
||||||
|
boundaries. Delivering defects first and cleanup separately would reduce the
|
||||||
|
initial review surface, but would retain redundant work and revisit those
|
||||||
|
boundaries. The combined approach is selected.
|
||||||
|
|
||||||
|
Efficiency and broader structural choices are addressed separately below. This
|
||||||
|
decision does not select new public behavior or establish an implementation
|
||||||
|
sequence.
|
||||||
|
|
||||||
|
### OQ-2. Which Efficiency Candidates Should Accompany Remediation?
|
||||||
|
|
||||||
|
**Settled:** include the four localized efficiency improvements below. E01/E02
|
||||||
|
are addressed separately with collection-policy preparation and evidence
|
||||||
|
ownership (E03) under the settled OQ-3 decision.
|
||||||
|
|
||||||
|
| Selected improvement | Scope and dependency |
|
||||||
|
| --- | --- |
|
||||||
|
| E04: tier-history projection lookups | Reuse a local lookup index or equivalent traversal while preserving authored coordinates and projection evidence. |
|
||||||
|
| E05: compound identity restoration | Avoid repeated mapping scans while preserving child identity, mapping order, and detached nodes. |
|
||||||
|
| A05: dynamic-type eligibility reuse | Reuse only completed successful checks within the operation, after A02 repairs descriptor construction failure state. |
|
||||||
|
| F08: secret-failure location lookup | Include at lower priority; prepare lookup state lazily so successful loads avoid unnecessary indexing. |
|
||||||
|
|
||||||
|
Together with OQ-1, this selects 25 review entries. These local changes address
|
||||||
|
source-demonstrated repeated work without requiring public APIs, persistent
|
||||||
|
caches, or a new architectural model. Preserve their behavioral acceptance
|
||||||
|
cases and keep implementation proportionate. E01/E02 involve policy retirement
|
||||||
|
timing and mutable ownership, so their scope belongs with the E03 discussion.
|
||||||
|
|
||||||
|
Deferring all six until consumer profiling was considered. That would reduce
|
||||||
|
initial scope and avoid optimizing uncommon workloads, but would retain known
|
||||||
|
growth patterns with relatively direct remedies in the four selected cases.
|
||||||
|
The selected approach does not promise a measured startup improvement; practical
|
||||||
|
benefit depends on configuration shape and workload. Broader experiments in
|
||||||
|
[future.md](future.md#5-performance-experiments) remain deferred. C02's availability
|
||||||
|
fix and A04/D01's existing-budget enforcement remain selected independently.
|
||||||
|
|
||||||
|
### OQ-3. How Much Structural Refactoring Should Be Selected?
|
||||||
|
|
||||||
|
**Settled:** select E01, E02, E03, and F02. Defer C03 and F07 to the first two
|
||||||
|
entries of [future.md](future.md) for consideration after the rest of this roadmap
|
||||||
|
is complete. F01's removal of the test-only protocol remains selected under OQ-1.
|
||||||
|
|
||||||
|
| Selected work | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| E01/E02/E03: collection-policy retirement, copying, and evidence ownership | Address the related costs and preparation/application boundaries together, retaining distinct scaling and behavioral acceptance cases. |
|
||||||
|
| F02: active acquisition and coordinator handoffs | Extract cohesive repeated operations with clear inputs/results while preserving visible phase order and ordinary/layered differences. |
|
||||||
|
|
||||||
|
For collection policy, distinguish read-only existing state, policy affected by
|
||||||
|
incoming declarations or replacements, and the changes/evidence retained by the
|
||||||
|
assembly. Preserve retirement timing, newly established policy, explicit-mode
|
||||||
|
persistence, and nested record ownership. A smaller function or fewer copies
|
||||||
|
alone does not establish success. For acquisition, moving blocks into helpers
|
||||||
|
that continue sharing mutable surrounding state would not clarify the handoff;
|
||||||
|
keep the existing loading model and named responsibilities.
|
||||||
|
|
||||||
|
The selected combination offers a focused improvement in efficiency and
|
||||||
|
maintainability at active ownership boundaries. Including C03/F07 now would
|
||||||
|
broaden review for more modest readability benefits; neither is necessary for
|
||||||
|
the selected fixes. They remain plausible follow-up work, not rejected proposals.
|
||||||
|
Deferring all structural work would reduce immediate change scope but retain the
|
||||||
|
policy inefficiencies and difficult acquisition handoffs. No new public API,
|
||||||
|
generic framework, or configuration semantics are selected.
|
||||||
|
|
||||||
|
Together, OQ-1 through OQ-3 select 29 entries. All scope questions are settled;
|
||||||
|
the next step is to prepare the implementation plan with sequencing and focused
|
||||||
|
validation. No implementation has been performed by these planning decisions.
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
# ConfigKit Consolidation Toward 1.0
|
|
||||||
|
|
||||||
Status: selected target state; implementation pending. This document defines the
|
|
||||||
refactor's scope and rationale; it does not authorize a release. The scope
|
|
||||||
questions are settled. The [implementation plan](implementation.md) owns the
|
|
||||||
concrete steps, sequencing, and implementation progress.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Consolidate the machinery introduced by recent import, collection, inheritance,
|
|
||||||
and discovery work so ConfigKit is easier to understand, maintain, and adopt
|
|
||||||
before its public contracts become stable. Success means fewer independently
|
|
||||||
maintained paths through the same behavior, clearer state ownership, and concise
|
|
||||||
consumer workflows. Reducing a function's line count is useful only when it
|
|
||||||
serves those outcomes.
|
|
||||||
|
|
||||||
The review considered public API consolidation as well as internal extraction.
|
|
||||||
The settled public direction retains the existing simple and layered loading APIs,
|
|
||||||
consolidates their private translation and acquisition machinery, and removes
|
|
||||||
unused reverse inspection conversions. No public signature or configuration
|
|
||||||
semantic change is planned in this effort. This is an assessment of the reviewed
|
|
||||||
consumer workflows, not a requirement to preserve every beta API in perpetuity.
|
|
||||||
|
|
||||||
## Evidence And Baseline
|
|
||||||
|
|
||||||
Reviewed worktree: `c9574ef`, immediately after the `v0.10.0` release at `5959537`.
|
|
||||||
This document replaces the earlier `complexity.md` roadmap and `remediation.md`
|
|
||||||
audit record. Their recommendations and relevant evidence are incorporated here;
|
|
||||||
the original documents remain available in Git history:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git show fbaaaf1:docs/roadmap/complexity.md
|
|
||||||
git show fbaaaf1:docs/roadmap/remediation.md
|
|
||||||
```
|
|
||||||
|
|
||||||
The earlier roadmap reviewed `7a83704f43ef9870db33661231cdff5d6ee502f7`, before
|
|
||||||
discovery. The audit reviewed `5959537d1adc2516e7f7e8881df72b707ec48c3f`, including
|
|
||||||
implemented discovery. The consolidation review checked their recommendations
|
|
||||||
against that newer baseline and also considered the public API.
|
|
||||||
|
|
||||||
This review inspected the current public load/input/discovery declarations and
|
|
||||||
maintained consumer examples, the shared request executor and acquisition flow,
|
|
||||||
import parsing and selection, inheritance execution, reflective projection and
|
|
||||||
cloning, schema field construction, and filesystem-context derivation. Graph
|
|
||||||
caller traces and text-reference checks confirmed the unused reverse conversion
|
|
||||||
chain. This was source review, not a new runtime correctness or performance audit.
|
|
||||||
Neither this review nor the audit established a runtime correctness defect.
|
|
||||||
|
|
||||||
### Audit Measurements
|
|
||||||
|
|
||||||
The audit refreshed the codebase-memory graph and used function size, cognitive
|
|
||||||
complexity, branch counts, and parameter counts to rank candidates for source
|
|
||||||
inspection. It indexed 901 non-test functions/methods and 928 test-file
|
|
||||||
declarations. Its fast index excludes examples, documentation, and scripts; a
|
|
||||||
tracked-file inventory covered those separately. Standalone `gocyclo`, `gocognit`,
|
|
||||||
`scc`, and `cloc` tools were unavailable.
|
|
||||||
|
|
||||||
These are the graph tool's reported measurements at the audited revision.
|
|
||||||
Function spans include comments and blank lines. The scores support relative
|
|
||||||
triage; they are not standardized acceptance thresholds or evidence of a defect.
|
|
||||||
|
|
||||||
| Function | Source | Lines | Cognitive score |
|
|
||||||
| --- | --- | ---: | ---: |
|
|
||||||
| `valueOperation.project` | [projection.go](../../internal/engine/projection.go) | 195 | 174 |
|
|
||||||
| `parseImportDeclarationSpec` | [import_spec.go](../../internal/engine/import_spec.go) | 127 | 129 |
|
|
||||||
| `valueOperation.clone` | [clone.go](../../internal/engine/clone.go) | 130 | 104 |
|
|
||||||
| `schemaBuilder.describeFields` | [schema.go](../../internal/engine/schema.go) | 99 | 100 |
|
|
||||||
| `assembleBaseInputTrackedForRole` | [load_input.go](../../internal/engine/load_input.go) | 188 | 96 |
|
|
||||||
| `selectDirectoryImportCandidates` | [import_selection.go](../../internal/engine/import_selection.go) | 154 | 96 |
|
|
||||||
| `valueBuilder.build` | [value.go](../../internal/engine/value.go) | 104 | 93 |
|
|
||||||
| `decodeValue` | [decode.go](../../internal/engine/decode.go) | 115 | 85 |
|
|
||||||
| `applyInheritanceScopeTemplates` | [inheritance_schedule.go](../../internal/engine/inheritance_schedule.go) | 219 | 75 |
|
|
||||||
| `contextsForAcquiredSources` | [source_context.go](../../internal/engine/source_context.go) | 102 | 72 |
|
|
||||||
|
|
||||||
At that revision, the engine contained 88 production Go files and approximately
|
|
||||||
20,144 physical lines; the root facade contained 16 files and 1,861 lines.
|
|
||||||
`imports.go` was the largest production file at 1,195 lines. These measurements
|
|
||||||
locate concentrated responsibilities. They do not establish a need for more
|
|
||||||
packages or make the smaller document/YAML visitors and explicit facade
|
|
||||||
conversions maintenance defects.
|
|
||||||
|
|
||||||
### Findings And Recommended Treatment
|
|
||||||
|
|
||||||
The finding numbers below preserve the audit's identifiers. The selected work
|
|
||||||
remains unimplemented; the cloning portion of finding 3 is deferred to future
|
|
||||||
work. Retiring the original documents does not mark their findings implemented.
|
|
||||||
|
|
||||||
| Audit finding | Maintenance priority | Assessment and recommended treatment |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1. Import entry points and responsibilities | Higher | Main refactor. Include shared acquisition normalization, import-default ownership, declaration parsing, and directory admission. |
|
|
||||||
| 2. Shared inheritance executor | Higher | Main refactor. Expose meaningful phases and explicit combination results while retaining one executor. |
|
|
||||||
| 3. Projection and cloning | Higher for projection; cloning deferred | Main refactor for projection. Clone traversal review is tracked separately in [future.md](future.md#4-clone-traversal-review). |
|
|
||||||
| 4. Schema field discovery and inline assembly | Medium | Include a focused refactor coordinated with projection. Keep one schema builder and its two descriptor views. |
|
|
||||||
| 5. Resolved filesystem context | Medium | Include alongside acquisition work. Reuse resolved facts without merging resource lifetimes or changing validation timing. |
|
|
||||||
| 6. Unused reverse inspection conversions | Small, independently reviewable cleanup | Include in the root facade and engine. Retain useful forward-conversion and detached-result coverage. |
|
|
||||||
|
|
||||||
Findings 1–3 confirm the earlier roadmap's main opportunities; findings 4–6 add
|
|
||||||
focused work. The following sections own their rationale and intended changes.
|
|
||||||
Implementation and validation will establish completion of the selected work;
|
|
||||||
the deferred clone review retains its separate disposition.
|
|
||||||
|
|
||||||
Completed correctness and algorithmic remediations remain part of the baseline,
|
|
||||||
including sequence records preserved at `ea46001:docs/roadmap/remediation.md`
|
|
||||||
and discovery records preserved at `0369111:docs/roadmap/remediation.md`.
|
|
||||||
Item-scope application matching and inheritance policy localization are examples
|
|
||||||
of completed corrections that overlap this work and whose regressions should
|
|
||||||
remain protected.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md),
|
|
||||||
[configuration contract](../config.md), and exported declaration comments own
|
|
||||||
existing constraints and behavior. The
|
|
||||||
[component overview](../internal/overview.md) and
|
|
||||||
[import internals](../internal/imports.md) locate implementation responsibilities.
|
|
||||||
|
|
||||||
## Consumer-Facing Target
|
|
||||||
|
|
||||||
### Keep Simple Loading Simple And Advanced Inputs Explicit
|
|
||||||
|
|
||||||
The current APIs already provide progressive setup:
|
|
||||||
|
|
||||||
- A simple consumer calls `Load[Config](File(path))`.
|
|
||||||
- A consumer with defaults, imports, or resolution supplies `Options[Config]`.
|
|
||||||
- Explicit discovery produces an ordinary `Source`, so it uses the same loading
|
|
||||||
call and lifecycle.
|
|
||||||
- A layered consumer supplies an `Input` and `LayeredOptions[Config]`, with
|
|
||||||
`Underlay.Inputs` distinguishing strict peers from precedence tiers.
|
|
||||||
|
|
||||||
The two loading functions already converge on `loadRequest` and
|
|
||||||
`executeLoadRequest`. Their public differences have meaning: ordinary options
|
|
||||||
include the one input's controls; layered loading has per-input controls and a
|
|
||||||
separate overlay filesystem. Ordinary overlay context follows its selected
|
|
||||||
source, whereas layered overlay context is independently specified. These are
|
|
||||||
observable conventions, not interchangeable forwarding spellings.
|
|
||||||
|
|
||||||
**Decision (OQ-1, settled):** retain `Load`, `LoadLayered`, `Options`, `LayeredOptions`,
|
|
||||||
`Input`, and `Underlay`. Remove repeated translation and option reconstruction
|
|
||||||
behind them. Keep common application rules in `Definition`; do not require
|
|
||||||
applications to build a runtime loader or manually coordinate phases.
|
|
||||||
|
|
||||||
A single public request type could place main input, underlays, and common
|
|
||||||
options together. It would reduce duplicated option fields and make promotion
|
|
||||||
from ordinary to layered loading more uniform. A convenience entry could preserve
|
|
||||||
the simplest call. Such a design would still need to resolve the existing
|
|
||||||
overlay-context differences and demonstrate a clearer consumer model. The
|
|
||||||
maintained examples do not show enough consumer benefit to justify that redesign
|
|
||||||
in this effort. Retaining the current APIs accepts some duplicated public option
|
|
||||||
fields and setup changes when adopting underlays; internal consolidation and
|
|
||||||
clearer guidance address the observed maintenance needs. The decision is based on
|
|
||||||
the resulting API, rather than the amount of refactoring needed to achieve it.
|
|
||||||
|
|
||||||
### Preserve Useful Convenience And Ownership Boundaries
|
|
||||||
|
|
||||||
| Surface reviewed | Recommended public end state |
|
|
||||||
| --- | --- |
|
|
||||||
| `Source`, `Filesystem`, `Search`, filename groups and presets | Retain typed, reusable source/search descriptions. Discovery selects a concrete source during loading; applications do not loop over complete loads. |
|
|
||||||
| `FilesystemContext` | Retain the public description and current host/native rules. Consolidate its resolved private representation; do not expose acquisition handles or engine context records. |
|
|
||||||
| `SequenceIdentityAt` and `SequenceIdentityMatching` | Retain the exact-path convenience and dynamic selector form over the common registration model. Both serve distinct consumer shapes. |
|
|
||||||
| `Definition`, resolution and validation callbacks | Retain application-owned domain rules inside one recorded lifecycle. Preserve the distinction between mutable recorded resolution and read-only validation. |
|
|
||||||
| Results, metadata, explanations, source context and diff | Retain detached, safe inspection with consumer-owned presentation. Remove unused inverse conversions without removing supported queries. |
|
|
||||||
| Root wrappers and private conversions | Retain root-owned public types and explicit ownership conversion under ADR 0006. Consolidate repeated real conversions and delete unused ones. |
|
|
||||||
|
|
||||||
The consumer-facing work is to make the ordinary path easier to find and show
|
|
||||||
how additional features compose, using the existing maintained examples. Update
|
|
||||||
the adoption guide with a concise choice between simple loading, discovery, and
|
|
||||||
layered loading, and show which options belong to an input versus a whole load.
|
|
||||||
Keep exact behavior in its canonical owners. Examples should make application
|
|
||||||
rules recognizable and avoid requiring consumers to recreate generic merging,
|
|
||||||
presence, or provenance machinery.
|
|
||||||
|
|
||||||
Use the current simple README call, bundled-file and layered catalog examples,
|
|
||||||
the import-focused prompt catalog, and the discovered sequence catalog to assess
|
|
||||||
the end state. The latter exercises imports, underlays, independent overlay
|
|
||||||
context, CLI assignment, and recorded resolution in one workflow. A high number
|
|
||||||
of options in that example does not by itself justify a new abstraction.
|
|
||||||
|
|
||||||
## Internal Target
|
|
||||||
|
|
||||||
Keep the root facade, cohesive `internal/engine`, ordered document model, and
|
|
||||||
private YAML adapter. Use ordinary private records, functions, and focused files
|
|
||||||
to express responsibilities. Existing mapping combination, sequence combination,
|
|
||||||
dependency scheduling, schema descriptors, and traversal accounting remain shared.
|
|
||||||
|
|
||||||
`executeLoadRequest` stays the visible lifecycle coordinator. It should show
|
|
||||||
acquisition, assembly, inheritance, decoding/CLI assignment, resolution, secret
|
|
||||||
processing, validation, and publication in their established order. Extract a
|
|
||||||
phase only when it gives that work a clear input, output, or ownership boundary.
|
|
||||||
Do not replace the visible lifecycle with a registry of configurable stages.
|
|
||||||
|
|
||||||
### 1. Normalize Loading Inputs And Consolidate Import Acquisition
|
|
||||||
|
|
||||||
The current executor constructs an engine `Options[T]` from `loadRequest`, and
|
|
||||||
underlay acquisition constructs `Options[struct{}]` to call the same input
|
|
||||||
assembler. `assembleBaseInputTrackedForRole` then combines discovery, directive
|
|
||||||
processing, version rules, imports, placement, accounting, and contexts. Import
|
|
||||||
entry points carry long positional lists through several forwarding variants.
|
|
||||||
This repeats the representation of information already normalized for a load.
|
|
||||||
|
|
||||||
In [imports.go](../../internal/engine/imports.go), the
|
|
||||||
`loadImportedDocumentsTrackedWithVersion`,
|
|
||||||
`loadImportedDocumentsTrackedWithProcessing`, and
|
|
||||||
`loadImportedDocumentsTrackedWithProcessingDeferred` variants have corresponding
|
|
||||||
`loadImportedWithAccess...` variants. Both final signatures carry 13 parameters.
|
|
||||||
Placement, processing results, sequence policy, accounting, repetition tracking,
|
|
||||||
namespace identity, and deferred assembly must stay aligned through the chain.
|
|
||||||
The same file's `importTraversal.visitPath` combines source-admission decisions
|
|
||||||
in 174 lines, alongside acquisition, pending-item assembly, and evidence work.
|
|
||||||
|
|
||||||
Introduce cohesive private acquisition inputs and explicit state ownership,
|
|
||||||
reusing `loadRequest`, `loadInput`, `acquisitionScope`, and `importTraversal`
|
|
||||||
where they fit. Translate ordinary and layered public declarations into the
|
|
||||||
shared loading model once. Acquisition should receive the source, input role,
|
|
||||||
placement, context, and policies it needs without a fabricated application type
|
|
||||||
or a reconstructed broad options object. Small adapters may preserve meaningful
|
|
||||||
entry operations while callers migrate; remove obsolete forwarding variants
|
|
||||||
within the completed refactor.
|
|
||||||
|
|
||||||
Separate immutable policy, mutable processing state, and results. Move import
|
|
||||||
defaults out of `sourceVersionState` and into the load/acquisition policy owner.
|
|
||||||
Version state continues to own version processing, projected effective version
|
|
||||||
location, evidence, and diagnostics. Local and effective version rules have
|
|
||||||
different meanings and must remain distinguishable.
|
|
||||||
|
|
||||||
The state boundaries are especially important:
|
|
||||||
|
|
||||||
| Lifetime or scope | State and responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| Whole load | Aggregate resource accounting, application definition/registrations, import defaults, shared version evidence and diagnostics. |
|
|
||||||
| One strict tier | Repetition tracking shared by that tier's inputs, namespace scheduling anchors, and its inclusion/composition graph relationship. A later tier may reuse a file. |
|
|
||||||
| Active import traversal | Active routes and inclusion edges, filesystem access ownership, and cycle detection, including edges crossing item-scope boundaries. |
|
|
||||||
| One composition scope | Peer contributions, pending collection declarations, item-local helpers, and deferred item placement. Nested scopes do not become separate precedence tiers. |
|
|
||||||
| Retained result | Source records, effective destinations, origins, history, and passive resource-context descriptions. |
|
|
||||||
|
|
||||||
Keep repetition tracking tier-scoped. In the current code,
|
|
||||||
`acquireUnderlayGroupScopes` creates a fresh visited map per group. Preserve valid
|
|
||||||
reuse across tiers while keeping shared aggregate limits. Namespace equality,
|
|
||||||
candidate spelling, and stable scheduling keys also serve different purposes.
|
|
||||||
|
|
||||||
Give acquisition, already-acquired scope assembly, and evidence projection
|
|
||||||
focused homes. Keep discovery at the acquisition boundary: resolve a search once
|
|
||||||
per input consumption, then process the concrete file or absence. Preserve
|
|
||||||
source IDs and source-local version processing in acquisition order, even when
|
|
||||||
composition uses a different dependency-ready order. The layered main is acquired
|
|
||||||
before underlays; lower-tier identity can still inform its later strict assembly.
|
|
||||||
|
|
||||||
Within `parseImportDeclarationSpec`, extract boolean/collection/placement field
|
|
||||||
handling and selector-value parsing where it clarifies responsibilities. Keep
|
|
||||||
field presence, authored error locations, selector applicability, and the order
|
|
||||||
of consequential validation visible. Within `selectDirectoryImportCandidates` and
|
|
||||||
`importTraversal.visitPath`, separate admission and descent decisions from
|
|
||||||
traversal orchestration where they represent distinct work. Retain the existing
|
|
||||||
root deduplication, candidate union, exclusions, sorting, and active-path cycle
|
|
||||||
rules. No shared abstraction should conflate literal first-match main discovery
|
|
||||||
with multi-file import selection.
|
|
||||||
|
|
||||||
### 2. Share Resolved Filesystem Facts Without Sharing Live Handles
|
|
||||||
|
|
||||||
`importFilesystem`, `nativeFilesystemAccess`, and `contextsForAcquiredSources`
|
|
||||||
derive overlapping root/base/namespace facts. The latter reconstructs context
|
|
||||||
from combinations of source kind and optional fields, including absent input.
|
|
||||||
Discovery and independent layered overlays make these combinations consequential.
|
|
||||||
Context construction is used by ordinary acquisition and both selected-overlay
|
|
||||||
paths, so those callers need to use the same applicable derivation rules.
|
|
||||||
|
|
||||||
Use a small engine-owned resolved-context description for the facts actually
|
|
||||||
needed by acquisition and passive views: namespace identity/kind, resolved root,
|
|
||||||
source base, and selected source path when present. Preserve the distinction
|
|
||||||
between host paths and native slash paths. Reuse already resolved facts where
|
|
||||||
available instead of repeatedly deriving them from the original options.
|
|
||||||
|
|
||||||
Resolution and validation can occur at different existing lifecycle points.
|
|
||||||
The record must not force every source to perform all filesystem checks early.
|
|
||||||
Context descriptions, library-owned acquisition handles, and returned passive
|
|
||||||
resource views have different lifetimes. Closing acquisition must remain
|
|
||||||
explicit on success and failure; retaining context must not retain an open root
|
|
||||||
handle or assume ownership of a borrowed `fs.FS`.
|
|
||||||
|
|
||||||
Cover selected and absent discovery outcomes, root-only absent host context,
|
|
||||||
native sources, in-memory inputs with explicit context, and independently rooted
|
|
||||||
overlays. Passive context remains associated with the source that contributed
|
|
||||||
the value, including retained underlay and inherited values. Use the acquired
|
|
||||||
source's canonical identity where available. Preserve containment failures,
|
|
||||||
source associations, deferred search bases, and terminal selected-file errors.
|
|
||||||
|
|
||||||
Acquisition and context handling use the same private handoff rather than
|
|
||||||
maintaining parallel models for overlapping callers. The description does not
|
|
||||||
replace the public `FilesystemContext` or `ResourceContext` contracts.
|
|
||||||
|
|
||||||
### 3. Make Inheritance Combination Results Explicit
|
|
||||||
|
|
||||||
Keep the mapping and identified-record adapters and one shared executor.
|
|
||||||
`applyInheritanceScopeTemplates` currently prepares templates, mutates values
|
|
||||||
through merge callbacks, projects policy and origins, records replacements and
|
|
||||||
replay evidence, and enforces budgets in one body. The callbacks mutate several
|
|
||||||
surrounding result collections, making a combination difficult to review alone.
|
|
||||||
|
|
||||||
Give the executor named operations for child/control preparation, parent payload
|
|
||||||
and policy preparation, combination, evidence construction, and retained-node
|
|
||||||
accounting. Use a small private result for outputs of one combination where it
|
|
||||||
replaces unrelated surrounding mutations. Value, policy, origin, replacement,
|
|
||||||
and sequence-position evidence should come from the same combination outcome;
|
|
||||||
do not rerun a merge to reconstruct its history.
|
|
||||||
|
|
||||||
Keep borrowed authored templates separate from owned resolved payloads and from
|
|
||||||
retained history snapshots. Parent identity exclusion and child identity/origin
|
|
||||||
preservation must apply consistently to values and history. Preserve nested
|
|
||||||
unresolved instructions for destination-local evaluation, policy retirement on
|
|
||||||
actual replacement, cancellation, and source-version movement through sequences.
|
|
||||||
|
|
||||||
The shared Kahn scheduler with stable keys remains unchanged. Resolve enclosing
|
|
||||||
relationships before nested relationships and retain assembled record order.
|
|
||||||
Preserve expansion limits and retained-history accounting for shared nodes;
|
|
||||||
extraction must not charge shared evidence twice or make an unbounded snapshot.
|
|
||||||
|
|
||||||
### 4. Separate Projection Handlers And Clarify Schema Assembly
|
|
||||||
|
|
||||||
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
|
|
||||||
the operation-state owner. Move substantial struct, map, and sequence projection
|
|
||||||
into focused handlers. Keep pointer/dynamic dispatch and scalar representation
|
|
||||||
choices visible. All handlers share the same cycle, depth, and node state.
|
|
||||||
|
|
||||||
Struct projection needs particular care: physical members drive traversal,
|
|
||||||
flattened fields drive name lookup and sensitivity, and an inline container is
|
|
||||||
not retained as an extra output node. Preserve declaration order, deterministic
|
|
||||||
map order, nil/empty distinctions, collision diagnostics, codec failures, and
|
|
||||||
secret classification. Scalar projection retains the existing number, duration,
|
|
||||||
text-codec, and secret-reference representations. Keep cycle entries active
|
|
||||||
through child traversal and balance their release on every exit.
|
|
||||||
|
|
||||||
In `schemaBuilder.describeFields`, separate field discovery and tag parsing from
|
|
||||||
inline struct assembly, inline map assembly, and ordinary field registration.
|
|
||||||
Retain one builder/cache and its recursion detection. Make the paired updates to
|
|
||||||
`members`, flattened `fields`, `fieldByName`, and `inlineMap` easy to follow.
|
|
||||||
Keep index-path construction and inherited secret flags intact. These descriptor
|
|
||||||
views are both needed; combining them into one collection would shift complexity
|
|
||||||
into decoding and projection.
|
|
||||||
Keep pointer-chain and recursive-inline decisions explicit, preserve supported
|
|
||||||
field/tag forms and ambiguity paths, and retain cache/building-state behavior.
|
|
||||||
|
|
||||||
Clone traversal review and cleanup are deferred to
|
|
||||||
[future.md](future.md#4-clone-traversal-review). This effort preserves cloning's
|
|
||||||
existing behavior while changing the shared schema and projection machinery.
|
|
||||||
Keep projection's document-building semantics distinct from typed copying.
|
|
||||||
The mostly explicit dispatch in `decodeValue` and `valueBuilder.build` does not
|
|
||||||
warrant a generic reflection visitor solely because it has a high complexity
|
|
||||||
score.
|
|
||||||
|
|
||||||
### 5. Remove Unused Inspection Conversions And Consolidate Real Adapters
|
|
||||||
|
|
||||||
Remove `toEngineSourceInfo`, `toEngineOrigin`, `toEngineSourceVersionCheck`, and
|
|
||||||
`toEngineImportSelections` from [conversion.go](../../conversion.go), along with
|
|
||||||
the engine adapters used only by that chain.
|
|
||||||
At the reviewed revision, the three callers of `toEngineSourceInfo` are tests;
|
|
||||||
`toEngineOrigin` has none. `SourceInfoWithPlacement`,
|
|
||||||
`SourceInfoWithImportSelections`, `importSelectorFromString`, and
|
|
||||||
`importSelectionOutcomeFromString` in [records.go](../../internal/engine/records.go)
|
|
||||||
have no production role beyond that reverse chain. Consumers receive inspection
|
|
||||||
records; production facade operations do not send those records back to the
|
|
||||||
engine. The unused inverse path adds another ownership mapping to maintain as
|
|
||||||
inspection evidence grows. Recheck callers at the implementation baseline before
|
|
||||||
removal.
|
|
||||||
|
|
||||||
Rewrite the affected [record tests](../../records_test.go) around real
|
|
||||||
engine-to-public conversion and detached public inspection. Preserve useful
|
|
||||||
placement, version evidence, import-selection, and copy-isolation assertions.
|
|
||||||
Avoid manufacturing replacement production setters solely to keep an old
|
|
||||||
round-trip fixture working. Existing facade loads already provide genuine sources
|
|
||||||
and inspection records.
|
|
||||||
|
|
||||||
Retain reverse conversions that serve real inputs: `toEngineDiagnostics`, for
|
|
||||||
example, supports `WriteDiagnostics`. Consolidate repeated override/input
|
|
||||||
conversion in `toEngineOptions` and `toEngineLayeredOptions` where semantics
|
|
||||||
match. Keep explicit root-to-engine ownership conversion and the public-boundary
|
|
||||||
checks. A reflective transport framework or public engine aliases would obscure
|
|
||||||
the boundary rather than simplify it.
|
|
||||||
|
|
||||||
## Scope, Compatibility, And Policy Consequences
|
|
||||||
|
|
||||||
The proposed work spans private engine code, private root-facade conversions,
|
|
||||||
affected tests, consumer guidance, and implementation documentation. Under the
|
|
||||||
settled answer to [OQ-1](#oq-1-should-the-public-loading-apis-change-in-this-effort),
|
|
||||||
preserve public types/signatures and the behavior owned by the current contracts,
|
|
||||||
including:
|
|
||||||
|
|
||||||
- Acquisition versus composition ordering, strict scopes versus precedence
|
|
||||||
tiers, identity ownership, placement, and source-version protection.
|
|
||||||
- Discovery exhaustion and error behavior, source lifetime, passive resource
|
|
||||||
contexts, and host/native namespace distinctions.
|
|
||||||
- Defaults, post-decode CLI assignment, recorded resolution, secret handling,
|
|
||||||
final registered-identity enforcement, and semantic validation.
|
|
||||||
- Explicit presence, provenance/history, sensitivity, safe diagnostics and diff,
|
|
||||||
frozen inspection, and detached publication/query results.
|
|
||||||
- Contracted diagnostic codes, paths, coordinates, ordering, and resource limits.
|
|
||||||
|
|
||||||
Do not turn incidental helper order, exact private representations, numerical
|
|
||||||
complexity scores, or allocation counts into new guarantees. Keep the existing
|
|
||||||
algorithmic remediations effective; watch for repeated whole-tree traversals,
|
|
||||||
policy scans, or copies introduced by helper extraction.
|
|
||||||
|
|
||||||
The recommendation fits the existing architecture policy and ADRs 0006,
|
|
||||||
0015, 0017, 0018, and 0019. It needs no new architectural ownership decision or
|
|
||||||
policy revision merely to introduce private helpers. Update the component
|
|
||||||
overview and import internals when code moves. Accepted ADR decision content
|
|
||||||
remains unchanged. A public loading-model redesign is outside this effort's
|
|
||||||
settled scope.
|
|
||||||
|
|
||||||
Pre-1.0 public changes are possible under the [release policy](../policy/release.md),
|
|
||||||
but they must have a consumer benefit and documented impact. This draft does not
|
|
||||||
propose a parallel compatibility API or temporary migration scaffold. It also
|
|
||||||
does not make refactoring completion a new release gate or establish that 1.0
|
|
||||||
is otherwise ready. Future performance experiments remain in
|
|
||||||
[future.md](future.md#3-performance-experiments); no benchmark campaign was run
|
|
||||||
or is required to claim readability improvements.
|
|
||||||
|
|
||||||
## Validation And Completion
|
|
||||||
|
|
||||||
Use the [development guide](../development.md) and
|
|
||||||
[testing policy](../policy/testing.md) for focused checks and the ordinary full
|
|
||||||
development check. Preserve existing behavior tests; rewrite tests coupled only
|
|
||||||
to removed plumbing. Add a test when a consequential handoff risk lacks coverage,
|
|
||||||
not for every new helper.
|
|
||||||
|
|
||||||
| Work area | Existing protection and review focus |
|
|
||||||
| --- | --- |
|
|
||||||
| Acquisition and imports | Selection, defaults, scheduling, relative/item placement, source-version evidence, aggregate limits, repetition within versus across tiers, and ordinary/layered equivalence. |
|
|
||||||
| Resolved contexts | Host/native access, discovery selected/absent outcomes, terminal context failures, source-relative resources, independent overlay context, and handle closure. |
|
|
||||||
| Inheritance | Mapping and record inheritance, nested destination-local evaluation, child identity, policy lifetime, version movement, history ordering, sensitivity, and retained-node budgets. |
|
|
||||||
| Projection/schema | Inline fields, recursive schemas, dynamic values, nil/empty values, cycles and limits, text codecs, secret states, callback ownership, and independent loads; preserve existing cloning behavior where shared descriptors/state are affected. |
|
|
||||||
| Facade and consumers | Forward conversion, detached records, supported API boundaries, source-form equivalence, maintained simple/catalog/discovery examples, and safe explanation/diff. |
|
|
||||||
|
|
||||||
Examples of established boundaries include
|
|
||||||
[source-form equivalence](../../facade_equivalence_test.go),
|
|
||||||
[discovery integration](../../facade_discovery_test.go),
|
|
||||||
[final identity validation](../../facade_final_sequence_identity_test.go),
|
|
||||||
[inheritance history](../../internal/engine/inheritance_history_test.go), and
|
|
||||||
[projection and cloning](../../internal/engine/projection_test.go).
|
|
||||||
Use applicable race checks when changing ownership or shared state. Reuse checks
|
|
||||||
for unchanged content and keep benchmark work separate unless new evidence
|
|
||||||
justifies it and it is authorized.
|
|
||||||
|
|
||||||
Completion requires reviewable evidence that:
|
|
||||||
|
|
||||||
- Simple and advanced consumer examples remain concise and valid, with no new
|
|
||||||
public concepts required for existing workflows.
|
|
||||||
- Both load entry points reach one normalized internal model; acquisition no
|
|
||||||
longer reconstructs broad options or uses version state to transport defaults.
|
|
||||||
- Import, context, inheritance, and reflection responsibilities have identifiable
|
|
||||||
owners and explicit handoffs without duplicate semantic implementations.
|
|
||||||
- Unused reverse conversions and their exclusive adapters are removed, with
|
|
||||||
useful inspection ownership coverage retained.
|
|
||||||
- Contracted values, presence, provenance/history, diagnostics, sensitivity,
|
|
||||||
resource lifetime, and algorithmic corrections remain protected by appropriate
|
|
||||||
tests and source review.
|
|
||||||
- The reviewed scope is accounted for against all six audit findings, with clone
|
|
||||||
traversal review deferred to future work. Completed portions have validation
|
|
||||||
evidence rather than being closed because code moved.
|
|
||||||
- Consumer guidance, contributor references, and internal documentation describe
|
|
||||||
the resulting implementation. Current contracts continue to own behavior.
|
|
||||||
|
|
||||||
The [implementation plan](implementation.md) defines private records, file owners,
|
|
||||||
and stage boundaries. No open scope questions remain, and no additional feature
|
|
||||||
semantics are needed to carry out the selected refactors.
|
|
||||||
|
|
||||||
## Settled Decisions
|
|
||||||
|
|
||||||
### OQ-1: Should The Public Loading APIs Change In This Effort?
|
|
||||||
|
|
||||||
**Settled:** retain `Load`/`Options`, `LoadLayered`/`LayeredOptions`, `Input`, and
|
|
||||||
`Underlay`, including their existing overlay-context behavior. Consolidate their
|
|
||||||
private inputs, conversions, and acquisition machinery, and improve consumer
|
|
||||||
guidance. The [consumer-facing target](#consumer-facing-target) records the
|
|
||||||
rationale and tradeoffs. This decision governs the consolidation effort; it does
|
|
||||||
not establish a perpetual restriction on future API design.
|
|
||||||
|
|
||||||
### OQ-2: How Much Clone Cleanup Should This Effort Commit To?
|
|
||||||
|
|
||||||
**Settled:** defer clone traversal review and any resulting cleanup to
|
|
||||||
[future.md](future.md#4-clone-traversal-review). Neither the review nor clone
|
|
||||||
refactoring is part of this consolidation effort. Projection and schema
|
|
||||||
refactoring remain in scope, including protection of existing cloning behavior
|
|
||||||
where they share descriptors or operation state. The future review can conclude
|
|
||||||
that no clone edits are warranted; its goal is demonstrated maintenance benefit.
|
|
||||||
@@ -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:
|
||||||
@@ -83,36 +91,3 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
|
|||||||
across loads without introducing aliasing?
|
across loads without introducing aliasing?
|
||||||
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
||||||
without excessive retained memory?
|
without excessive retained memory?
|
||||||
|
|
||||||
## 4. Clone traversal review
|
|
||||||
|
|
||||||
Status: deferred. Review typed configuration cloning after the projection and
|
|
||||||
schema consolidation work, when the resulting structure can inform the review.
|
|
||||||
This is future maintenance work, not a prerequisite for completing consolidation.
|
|
||||||
|
|
||||||
The complexity audit identified `valueOperation.clone` in
|
|
||||||
[clone.go](../../internal/engine/clone.go) as a candidate for cleanup. Much of its
|
|
||||||
size is straightforward type dispatch. Focused handlers may clarify allocation,
|
|
||||||
recursive copying, cycle tracking, secret-state handling, or text-codec
|
|
||||||
reconstruction, but extraction may also add indirection without making the code
|
|
||||||
easier to understand.
|
|
||||||
|
|
||||||
When this work is taken up, assess the traversal and record whether changes are
|
|
||||||
worthwhile; a conclusion that no refactor is needed is valid. Make focused
|
|
||||||
changes only where the maintenance benefit justifies the code movement. Keep
|
|
||||||
cloning's typed-copy semantics distinct from projection's document-building
|
|
||||||
semantics. Preserve detached values, nil/empty distinctions, physical inline
|
|
||||||
fields, cycle/depth checks, and unresolved versus validated secret handling.
|
|
||||||
Reuse existing ownership, codec, secret, and traversal tests, adding coverage for
|
|
||||||
consequential gaps. No performance improvement is assumed.
|
|
||||||
|
|
||||||
### Questions to resolve before implementation
|
|
||||||
|
|
||||||
- After projection and schema consolidation, which clone branches still mix
|
|
||||||
responsibilities enough to benefit from extraction?
|
|
||||||
- Would a proposed helper keep allocation, recursive copying, and cycle cleanup
|
|
||||||
easier to follow together, or merely move a short loop out of sight?
|
|
||||||
- Are any small shared operations semantically identical across traversals, or
|
|
||||||
would sharing require special cases that obscure their different purposes?
|
|
||||||
- Which ownership or special-value risks, if any, lack sufficient existing test
|
|
||||||
coverage for the proposed changes?
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
186
docs/roadmap/internal-boundaries.md
Normal file
186
docs/roadmap/internal-boundaries.md
Normal 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](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.
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Consolidation Review Findings
|
|
||||||
|
|
||||||
Reviewed revision: `557e006a4ab98b666735a71b3845418c702f7cec`.
|
|
||||||
Comparison baseline: `8ab1568` (the committed implementation plan).
|
|
||||||
|
|
||||||
The review compared the [consolidation target](consolidation.md) and
|
|
||||||
[implementation plan](implementation.md) with the completed sprint, including
|
|
||||||
public/private conversions, preflight and acquisition, import selection and
|
|
||||||
evidence, filesystem contexts, inheritance, projection, schema construction,
|
|
||||||
changed tests, and documentation. The development check (`./scripts/check.sh`)
|
|
||||||
and full race suite (`go test -race ./...`) passed with Go 1.26.10 on Linux.
|
|
||||||
|
|
||||||
## 1. Complete The Non-Import Resolved-Context Handoff
|
|
||||||
|
|
||||||
Status: open. Priority: medium. Category: implementation gap and maintainability.
|
|
||||||
No user-facing correctness regression has been established for this finding.
|
|
||||||
|
|
||||||
**Target.** Roadmap internal target 2 and implementation Stages 7–9 select one
|
|
||||||
resolved filesystem description shared by validation/acquisition and passive
|
|
||||||
context construction. Stage 8 explicitly requires populating the description
|
|
||||||
at the existing non-import validation point and resolving only facts that are
|
|
||||||
still missing later.
|
|
||||||
|
|
||||||
**Evidence.** In [acquisition.go](../../internal/engine/acquisition.go),
|
|
||||||
`acquireInput` calls `validateFilesystemContext` at line 181 for inputs without
|
|
||||||
import traversal and with an applicable explicit context. That validator returns
|
|
||||||
only a diagnostic. Its host/native branches derive a resolved description but
|
|
||||||
discard it. At lines 220–226, acquisition sees `input.resolvedFilesystem == nil`
|
|
||||||
and calls `resolveFilesystemContext` again before building passive views.
|
|
||||||
|
|
||||||
For example, an ordinary `Load` of a byte or mapping source with explicit host
|
|
||||||
`FilesystemContext` and imports disabled resolves/canonicalizes the base and
|
|
||||||
root during validation, then derives those same facts again for its source
|
|
||||||
context. The retained description therefore does not complete the intended
|
|
||||||
validation-to-context handoff.
|
|
||||||
|
|
||||||
Additionally, [filesystem.go](../../internal/engine/filesystem.go) still contains
|
|
||||||
`validateAbsentHostFilesystemContext` (line 40), with its own root/base
|
|
||||||
canonicalization, root-defaulting, and containment logic. That derivation
|
|
||||||
duplicates `resolveAbsentFilesystemContext` in
|
|
||||||
[filesystem_context.go](../../internal/engine/filesystem_context.go) (line 29).
|
|
||||||
The two paths already maintain separate containment-diagnostic messages. Graph
|
|
||||||
caller inspection confirms that the old validator is reachable from
|
|
||||||
`acquireInput`; it is not unused migration scaffolding.
|
|
||||||
|
|
||||||
**Why address it.** The new model exists, but one important path still discards
|
|
||||||
and reconstructs its results, while absent-host context derivation has two
|
|
||||||
production implementations. This leaves part of audit finding 5 unresolved and
|
|
||||||
preserves the drift risk the consolidation was intended to remove. This is a
|
|
||||||
structural completion issue, not a claim of measured performance loss or a new
|
|
||||||
single-lookup guarantee.
|
|
||||||
|
|
||||||
**Required revision.**
|
|
||||||
|
|
||||||
- Have non-import context resolution/validation return or populate the resolved
|
|
||||||
description, and retain it on `assembledInput` at the existing validation
|
|
||||||
point. Later passive-view construction and ordinary-overlay handling should
|
|
||||||
reuse those applicable facts.
|
|
||||||
- Route absent-host derivation through the common resolved-context owner and
|
|
||||||
remove the duplicate derivation from the old validator. Keep the existing
|
|
||||||
directory/access checks and close-error handling at their appropriate points.
|
|
||||||
- Preserve deferred discovery/ordinary-overlay checks, root-only absent host
|
|
||||||
context, host/native distinctions, diagnostics, and borrowed filesystem
|
|
||||||
ownership. Independently required validation must not disappear merely to
|
|
||||||
reduce filesystem calls.
|
|
||||||
- Keep internal documentation aligned with the completed handoff. Describe
|
|
||||||
aggregate accounting as load-wide and repetition/namespace state as
|
|
||||||
tier-scoped; the new internal overview/import prose currently calls the latter
|
|
||||||
per-tier accounting despite its distinct role.
|
|
||||||
|
|
||||||
**Verification.** Review the non-import call path to confirm that applicable
|
|
||||||
resolved facts are carried forward and absent-host derivation has one owner.
|
|
||||||
Reuse the existing host/native, discovery selected/absent, ordinary/layered
|
|
||||||
overlay, and passive-resource-context tests. Add a focused behavioral regression
|
|
||||||
only for a consequential gap exposed by the change; do not assert incidental
|
|
||||||
canonicalization/stat call counts. Run the development check and applicable
|
|
||||||
ownership race checks after remediation.
|
|
||||||
276
docs/roadmap/roadmap.md
Normal file
276
docs/roadmap/roadmap.md
Normal 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](audit.md#settled-scope-decisions).
|
||||||
|
It is intended for maintainers reviewing the remediation's scope and architecture.
|
||||||
|
|
||||||
|
## Document Ownership
|
||||||
|
|
||||||
|
- [audit.md](audit.md) owns finding IDs, original evidence, detailed remedies and
|
||||||
|
acceptance cases, and the settled scope decisions. Its evidence has not been
|
||||||
|
independently reproduced by writing this roadmap.
|
||||||
|
- This document owns the combined target state, work areas, and design intent.
|
||||||
|
Finding IDs below provide traceability without repeating the full audit inventory.
|
||||||
|
- The historical implementation plan at
|
||||||
|
`bb5540b:docs/roadmap/implementation.md` records concrete stages, private
|
||||||
|
implementation choices, edits, validation commands, and completion evidence.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md), public Go declarations, and
|
||||||
|
[architecture policy](../policy/architecture.md) remain the owners of behavior
|
||||||
|
and guarantees. Update their applicable implementation documentation when work
|
||||||
|
lands; this planning document does not redefine their contracts.
|
||||||
|
|
||||||
|
## Current Baseline And Targeted End State
|
||||||
|
|
||||||
|
The existing loading model remains the foundation. Public loading APIs delegate
|
||||||
|
to the shared engine lifecycle; acquisition retains source scopes for composition,
|
||||||
|
then precedence, inheritance, decoding, CLI overrides, application resolution,
|
||||||
|
secret lookup, and validation produce a detached result with inspection metadata.
|
||||||
|
The [component overview](../internal/overview.md) describes the current owners.
|
||||||
|
|
||||||
|
The audits identify failures at particular traversal, lifetime, error, and
|
||||||
|
accounting boundaries, alongside redundant representations and repeated work.
|
||||||
|
The target is to repair those boundaries and simplify their implementation while
|
||||||
|
preserving ConfigKit's public API and configuration model.
|
||||||
|
|
||||||
|
| Current gap | Targeted outcome |
|
||||||
|
| --- | --- |
|
||||||
|
| Recursive descriptors can fail to terminate, failed dynamic builds can leave partial state, and slice aliases can be mistaken for cycles. | Traversal terminates for the reported cases, failed construction leaves reusable state valid, and supported acyclic values remain accepted. |
|
||||||
|
| Callback and owned-root cleanup can be skipped during panic unwinding; directory errors lose their failure phase. | Cleanup follows the lifetime of each owned resource, and selected-directory failures retain the information needed for correct error handling. |
|
||||||
|
| Finite symlink dependencies can be expanded repeatedly. | Completed dependencies are reused within a resolution while active cycles remain detectable. |
|
||||||
|
| Projection reservations and published history can escape existing accounting. | Existing budgets govern the relevant allocations and complete retained document graph. |
|
||||||
|
| Collection-policy work repeatedly scans or copies unrelated state. | Policy preparation, application, and retained evidence have clear ownership, with work localized to affected policy. |
|
||||||
|
| Acquisition has redundant paths and difficult handoffs; several private representations have no production consumer. | Production paths and their tests share the active protocol, with cohesive handoffs and unused representations removed. |
|
||||||
|
| Some lookups repeat full scans, and some tests assert incidental representation or miss meaningful mutations. | Local indexing avoids the identified repeated work, and tests protect observable contracts at useful boundaries. |
|
||||||
|
|
||||||
|
This is a bounded remediation effort, not a redesign of configuration semantics.
|
||||||
|
Correcting erroneous acceptance, rejection, cleanup, or diagnostics in the
|
||||||
|
reported cases restores existing contracts. It does not select new loading APIs,
|
||||||
|
configuration options, limits, background work, or persistent caches.
|
||||||
|
|
||||||
|
## Selected Work
|
||||||
|
|
||||||
|
The following areas account for all 29 selected entries. Detailed examples and
|
||||||
|
acceptance cases remain with their linked audit group.
|
||||||
|
|
||||||
|
### 1. Safe Typed-Value Traversal And Descriptor Reuse
|
||||||
|
|
||||||
|
Selected entries: **A01, A02, A03, A04, A05** in
|
||||||
|
[schema and typed values](audit.md#a-schema-and-typed-values).
|
||||||
|
|
||||||
|
Make pointer-only descriptor traversal terminate in decoding and path resolution,
|
||||||
|
while preserving supported null values and ordinary recursive structures. Make
|
||||||
|
dynamic descriptor construction transactional: a failed build must not leave
|
||||||
|
partial descriptors available to subsequent operations. Staging successful
|
||||||
|
additions or rolling back the complete failed addition set are implementation
|
||||||
|
choices; both must handle recursive references and preserve prior valid entries.
|
||||||
|
|
||||||
|
Correct active-path cycle detection to distinguish finite slice views sharing
|
||||||
|
storage from actual cycles. Keep detachment and nil/empty behavior intact across
|
||||||
|
the callers that share this traversal machinery.
|
||||||
|
|
||||||
|
Enforce existing projection budgets before large container reservations. Include
|
||||||
|
map-key materialization and overflow-safe lower-bound checks in the review; a
|
||||||
|
check after the reservation does not address the reported failure. This work
|
||||||
|
does not introduce a general heap limit or reject large containers independently
|
||||||
|
of existing budgets.
|
||||||
|
|
||||||
|
Dynamic-type eligibility reuse depends on safe descriptor construction and retains
|
||||||
|
completed successful checks within the value operation. Keep failure diagnostics
|
||||||
|
specific to their current path and distinguish completed results from traversal visits.
|
||||||
|
This removes repeated descriptor-graph walks without a cross-load cache.
|
||||||
|
|
||||||
|
### 2. Callback, Filesystem, And Dependency Lifetimes
|
||||||
|
|
||||||
|
Selected entries: **B01, B02** in [callback lifetime](audit.md#b-callback-and-filesystem-lifetime)
|
||||||
|
and **C01, C02** in [filesystem traversal](audit.md#c-import-selection-and-link-resolution).
|
||||||
|
|
||||||
|
Tie callback-context deactivation to the callback invocation with deferred cleanup
|
||||||
|
so it runs on normal return and panic unwinding. Independently give the owned
|
||||||
|
import root a deferred cleanup fallback. Preserve normal close-error handling,
|
||||||
|
caller ownership, and propagation of the original callback panic. Fixing either
|
||||||
|
state lifetime or handle lifetime does not fix the other.
|
||||||
|
|
||||||
|
Preserve the phase of a directory failure. A genuinely absent directory before
|
||||||
|
successful opening remains subject to existing permissiveness rules; a later
|
||||||
|
read or close failure must not be treated as absence. Apply this distinction to
|
||||||
|
both supported directory-reading paths. Strengthen the shared filesystem tests
|
||||||
|
to check each handle's closure and structured, non-disclosing diagnostics,
|
||||||
|
including partial reads and failures masked by another successful directory.
|
||||||
|
Do not change regular-file close-error policy as a side effect.
|
||||||
|
|
||||||
|
Avoid repeatedly expanding completed finite symlink dependencies in both host
|
||||||
|
and native filesystem resolution. Use operation-local memoization of completed
|
||||||
|
targets, or an equivalent dependency evaluation, with keys that respect the
|
||||||
|
resolution namespace. Keep active-path cycle detection separate from completed
|
||||||
|
work. Preserve containment, supported finite reuse, and existing host-path
|
||||||
|
semantics. The fix must work for directory and glob selection without a new
|
||||||
|
arbitrary traversal cap or changed symlink defaults.
|
||||||
|
|
||||||
|
### 3. Retained History And Combination Evidence
|
||||||
|
|
||||||
|
Selected entries: **D01, D02, D03, D04** in
|
||||||
|
[history and retained state](audit.md#d-retained-history-and-unused-evidence).
|
||||||
|
|
||||||
|
Remove the unconsumed override-result pre-CLI copy and the unqueried published
|
||||||
|
structural-input copy. Preserve the separate pre-CLI state used by active replay
|
||||||
|
and the structural input still used before publication for metadata and secret
|
||||||
|
classification. Remove unused combination-evidence members and the work needed
|
||||||
|
solely to produce them, retaining projections, mappings, origins, and snapshots
|
||||||
|
that have active consumers.
|
||||||
|
|
||||||
|
With retained roots clarified, enforce the existing history allowance against
|
||||||
|
the complete graph published through inspection, including replay and inheritance
|
||||||
|
state and publication-created copies. Count shared nodes once by pointer identity;
|
||||||
|
distinct retained copies still consume the allowance. Account for required copies
|
||||||
|
before creating them where practical. Removing an unused tree alone does not
|
||||||
|
resolve the accounting omissions.
|
||||||
|
|
||||||
|
Keep required before/after evidence and existing inspection behavior. Validate
|
||||||
|
the complete publication path with small controlled allowances, including the
|
||||||
|
interaction of CLI, resolution replay, and inheritance, rather than relying only
|
||||||
|
on tests of an isolated counting helper.
|
||||||
|
|
||||||
|
### 4. Collection-Policy Preparation And Application
|
||||||
|
|
||||||
|
Selected entries: **E01, E02, E03** in
|
||||||
|
[collection policy and projection](audit.md#e-collection-processing-and-efficiency).
|
||||||
|
|
||||||
|
Distinguish the existing policy state used for lookup, changes introduced by an
|
||||||
|
incoming tier, and evidence retained by the assembly. Prepare incoming claims and
|
||||||
|
declarations against read-only lower state, then apply the owned changes at the
|
||||||
|
appropriate combination boundary. Avoid copying every unrelated policy for
|
||||||
|
small tiers and scanning all policies for each independent replacement.
|
||||||
|
|
||||||
|
Use a semantic-path index, localized updates, or equivalent bounded lookup to
|
||||||
|
find affected policy. Preserve retirement timing, policy established by the
|
||||||
|
incoming contribution, pending declarations, persistent explicit modes, and
|
||||||
|
nested record ownership. Copying remains appropriate wherever mutable ownership
|
||||||
|
requires it. Removing only one duplicate clone or shortening a function does not
|
||||||
|
address the identified growth patterns.
|
||||||
|
|
||||||
|
Clarify preparation, evidence application, and finalization responsibilities.
|
||||||
|
Share straightforward history-descriptor translation where useful; evaluate
|
||||||
|
shared record-selection logic while keeping overlay and inheritance origin and
|
||||||
|
mode lifetimes distinct. Retain the common sequence-combination owner. This work
|
||||||
|
does not call for a generic merging framework.
|
||||||
|
|
||||||
|
### 5. Active Acquisition Handoffs And Private Cleanup
|
||||||
|
|
||||||
|
Selected entries: **F01, F02, F03, F04, F05, F06** in
|
||||||
|
[acquisition, cleanup, and test quality](audit.md#f-remaining-maintenance-and-test-improvements).
|
||||||
|
|
||||||
|
Remove the test-only eager acquisition mode so acquisition retains scopes through
|
||||||
|
the active production protocol. Tests that need composition should invoke the
|
||||||
|
actual composition boundary explicitly. Retire the parallel test-only import
|
||||||
|
parser and route relevant fixtures through the active directive parser or the
|
||||||
|
appropriate declaration boundary, retaining malformed-input coverage.
|
||||||
|
|
||||||
|
Clarify active acquisition and coordinator handoffs around cohesive tasks:
|
||||||
|
parsed-input directive/version handling, standalone placement, and repeated tier
|
||||||
|
application/projection. Use explicit inputs and results consistent with existing
|
||||||
|
state owners. Extracting helpers that continue to share surrounding mutable state
|
||||||
|
does not establish a clearer handoff. Preserve ordinary/layered differences,
|
||||||
|
overlay and diff behavior, source identity, callback timing, diagnostics, and
|
||||||
|
accounting scopes.
|
||||||
|
|
||||||
|
Remove the disconnected root record-clone family, uncalled engine helpers, and
|
||||||
|
unread private transport/result members identified in the audit. Reconfirm
|
||||||
|
references on the implementation baseline, including function values and relevant
|
||||||
|
build configurations. Preserve live conversion paths and validation side effects;
|
||||||
|
the audit's absence of field reads is not a general rule for deleting state.
|
||||||
|
Replace meaningful ownership coverage through the public boundary before removing
|
||||||
|
tests that only exercise disconnected helpers.
|
||||||
|
|
||||||
|
### 6. Local Lookup Improvements
|
||||||
|
|
||||||
|
Selected entries: **E04, E05** in
|
||||||
|
[collection policy and projection](audit.md#e-collection-processing-and-efficiency)
|
||||||
|
and **F08** in [secret-failure lookup](audit.md#f08-index-secret-failure-location-queries-lazily).
|
||||||
|
|
||||||
|
Reuse a local document lookup or equivalent traversal for tier-history projection
|
||||||
|
instead of rescanning wide mappings for each sequence. Preserve authored
|
||||||
|
coordinates, sparse projections, and evidence ownership.
|
||||||
|
|
||||||
|
Restore compound identity fields using indexed mapping positions and membership
|
||||||
|
checks rather than rescanning child and merged mappings for every field. Preserve
|
||||||
|
child-authored values and locations, mapping order, and detached nodes.
|
||||||
|
|
||||||
|
Build secret-failure location lookup state lazily when failures need it. Preserve
|
||||||
|
all diagnostics, nearest-location fallbacks, and confidentiality; successful
|
||||||
|
loads should not incur an unnecessary index merely to support failure reporting.
|
||||||
|
These changes address specific repeated work and do not promise a measured
|
||||||
|
startup improvement.
|
||||||
|
|
||||||
|
### 7. Tests That Protect The Intended Behavior
|
||||||
|
|
||||||
|
Selected entries: **F09, F10, F11, F12** in
|
||||||
|
[acquisition, cleanup, and test quality](audit.md#f-remaining-maintenance-and-test-improvements),
|
||||||
|
in addition to the regression coverage attached to each reported defect.
|
||||||
|
|
||||||
|
Cover historical-path enumeration through the public API, including removed
|
||||||
|
paths, queryability, ordering, and detached returned slices. Use those assertions
|
||||||
|
to replace private representation checks where they address the same risk.
|
||||||
|
|
||||||
|
Strengthen fuzz detachment checks by mutating existing descendants or in-length
|
||||||
|
elements, so spare capacity cannot hide aliases. Replace exact filesystem stat
|
||||||
|
call sequences with observable candidate ordering and meaningful failure
|
||||||
|
selection, retaining relevant assertions such as not opening excluded inputs.
|
||||||
|
|
||||||
|
Consolidate facade scaffolding around public construction, loading, errors,
|
||||||
|
ownership, and lifecycle behavior. Preserve distinct integration risks and
|
||||||
|
reader-byte snapshot coverage. Existing coverage percentages or a desired test
|
||||||
|
count are not acceptance criteria for this work.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
The historical implementation plan at `bb5540b:docs/roadmap/implementation.md`
|
||||||
|
defines the sequential, Luna-scoped stages and their dependencies, including
|
||||||
|
coordinated changes to descriptor state, history ownership/accounting, collection
|
||||||
|
policy, acquisition, and replacement public coverage. It also records validation
|
||||||
|
commands and the complete finding-to-stage map. The audit remains the owner of
|
||||||
|
original evidence and detailed acceptance cases.
|
||||||
|
|
||||||
|
## Validation And Completion
|
||||||
|
|
||||||
|
Validation follows the audit's detailed acceptance cases and the
|
||||||
|
[testing policy](../policy/testing.md). For fatal or hanging regressions, use bounded
|
||||||
|
subprocesses; for budget cases, use small controlled limits. For symlink expansion
|
||||||
|
and repeated lookup work, prefer bounded operation evidence and algorithm review
|
||||||
|
over timing thresholds. Use real filesystem fixtures where filesystem semantics
|
||||||
|
matter and narrow fault injection for read/close failures. State platform limits.
|
||||||
|
|
||||||
|
Applicable development checks come from the [development guide](../development.md),
|
||||||
|
including appropriate race coverage for changed ownership and lifetime boundaries.
|
||||||
|
The historical implementation plan owns their execution. Existing audit results
|
||||||
|
are baseline evidence, not validation of the eventual changes. No performance
|
||||||
|
benchmark campaign is required by this roadmap, and no new benchmark results are
|
||||||
|
claimed.
|
||||||
|
|
||||||
|
Completion means all 29 selected findings have implementation and acceptance
|
||||||
|
evidence, the affected public behavior remains consistent with its contracts,
|
||||||
|
and canonical internal documentation describes the resulting owners. The
|
||||||
|
historical implementation plan records execution status without duplicate
|
||||||
|
progress inventories. Retire completed planning records under the normal
|
||||||
|
documentation lifecycle.
|
||||||
|
|
||||||
|
## Scope Boundaries And Decision Status
|
||||||
|
|
||||||
|
All three audit scope questions are settled. No significant architectural or
|
||||||
|
product decision remains open for this roadmap. The historical implementation
|
||||||
|
plan records private design choices and sequencing, subject to the acceptance
|
||||||
|
cases above.
|
||||||
|
If that work exposes a need to change a public contract, bring the specific
|
||||||
|
decision back for review rather than treating it as an implicit part of remediation.
|
||||||
|
|
||||||
|
The glob preparation refactor (C03) and wider facade transport simplification
|
||||||
|
(F07) have since been selected in the
|
||||||
|
[internal-boundary roadmap](internal-boundaries.md). Other deferred features,
|
||||||
|
clone-traversal redesign, and broader performance experiments remain outside
|
||||||
|
this effort. Keep
|
||||||
|
existing ownership boundaries and loading APIs; no ADR or architecture-policy
|
||||||
|
change is proposed by this roadmap.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
62
facade_historical_paths_test.go
Normal file
62
facade_historical_paths_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.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=
|
||||||
|
|||||||
@@ -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,67 +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)) {
|
|
||||||
if diagnostic := validateFilesystemContext(selectedSource, request.input.filesystem, parsed); diagnostic != nil {
|
|
||||||
return input, []Diagnostic{*diagnostic}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
73
internal/engine/callback_panic_test.go
Normal file
73
internal/engine/callback_panic_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func capturePanic(call func()) (recovered any) {
|
||||||
|
defer func() { recovered = recover() }()
|
||||||
|
call()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirePanicValue(t *testing.T, want any, call func()) {
|
||||||
|
t.Helper()
|
||||||
|
if got := capturePanic(call); got != want {
|
||||||
|
t.Fatalf("panic = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackContextsCloseWhenCallbacksPanic(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("resolution", func(t *testing.T) {
|
||||||
|
sentinel := errors.New("resolution panic sentinel")
|
||||||
|
var retained *Resolution
|
||||||
|
got := capturePanic(func() {
|
||||||
|
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
retained = resolution
|
||||||
|
panic(sentinel)
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
if got != sentinel {
|
||||||
|
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||||
|
}
|
||||||
|
requirePanicValue(t, closedCallbackContextMessage, func() { retained.IsExplicit(Keys("value")) })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("validation", func(t *testing.T) {
|
||||||
|
sentinel := errors.New("validation panic sentinel")
|
||||||
|
var retained *Validation
|
||||||
|
got := capturePanic(func() {
|
||||||
|
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) {
|
||||||
|
retained = validation
|
||||||
|
panic(sentinel)
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
if got != sentinel {
|
||||||
|
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||||
|
}
|
||||||
|
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Error(Keys("value"), "too late") })
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("version", func(t *testing.T) {
|
||||||
|
sentinel := errors.New("version panic sentinel")
|
||||||
|
var retained *Validation
|
||||||
|
got := capturePanic(func() {
|
||||||
|
_, _ = testLoad[config](Bytes("version.yml", []byte("version: v1\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
||||||
|
retained = validation
|
||||||
|
panic(sentinel)
|
||||||
|
}},
|
||||||
|
}})
|
||||||
|
})
|
||||||
|
if got != sentinel {
|
||||||
|
t.Fatalf("callback panic = %#v, want original sentinel", got)
|
||||||
|
}
|
||||||
|
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Warning(Keys("version"), "too late") })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,11 +14,7 @@ type collectionPolicy struct {
|
|||||||
type collectionPolicyResolution struct {
|
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)
|
||||||
|
|||||||
137
internal/engine/collection_policy_index.go
Normal file
137
internal/engine/collection_policy_index.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// collectionPolicyRetirementIndex locates only assembly policies whose YAML
|
||||||
|
// identity or record-owned state can be cleared by an ancestor replacement.
|
||||||
|
// Mapping keys and sequence indexes remain distinct through pathSegment keys.
|
||||||
|
type collectionPolicyRetirementIndex struct {
|
||||||
|
children map[pathSegment]*collectionPolicyRetirementIndex
|
||||||
|
entryKey string
|
||||||
|
hasEntry bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollectionPolicyRetirementIndex(scope collectionPolicyScope) *collectionPolicyRetirementIndex {
|
||||||
|
index := &collectionPolicyRetirementIndex{}
|
||||||
|
for _, entry := range scope.collections {
|
||||||
|
if collectionPolicyHasRetirementState(entry.policy) {
|
||||||
|
index.set(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) set(entry collectionPolicyEntry) {
|
||||||
|
node := index
|
||||||
|
for _, segment := range entry.destination.segments {
|
||||||
|
if node.children == nil {
|
||||||
|
node.children = make(map[pathSegment]*collectionPolicyRetirementIndex)
|
||||||
|
}
|
||||||
|
if node.children[segment] == nil {
|
||||||
|
node.children[segment] = &collectionPolicyRetirementIndex{}
|
||||||
|
}
|
||||||
|
node = node.children[segment]
|
||||||
|
}
|
||||||
|
node.entryKey = entry.destination.String()
|
||||||
|
node.hasEntry = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) remove(destination Path) {
|
||||||
|
if index == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes := make([]*collectionPolicyRetirementIndex, 1, len(destination.segments)+1)
|
||||||
|
nodes[0] = index
|
||||||
|
node := index
|
||||||
|
for _, segment := range destination.segments {
|
||||||
|
node = node.children[segment]
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
node.entryKey = ""
|
||||||
|
node.hasEntry = false
|
||||||
|
pruneCollectionPolicyIndex(nodes, destination.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) retire(scope *collectionPolicyScope, ancestor Path) {
|
||||||
|
if index == nil || scope == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes := make([]*collectionPolicyRetirementIndex, 1, len(ancestor.segments)+1)
|
||||||
|
nodes[0] = index
|
||||||
|
node := index
|
||||||
|
for _, segment := range ancestor.segments {
|
||||||
|
node = node.children[segment]
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
index.clearSubtree(scope, node)
|
||||||
|
pruneCollectionPolicyIndex(nodes, ancestor.segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) clearSubtree(scope *collectionPolicyScope, node *collectionPolicyRetirementIndex) {
|
||||||
|
if node.hasEntry {
|
||||||
|
if entry, found := scope.collections[node.entryKey]; found {
|
||||||
|
clearCollectionIdentity(&entry.policy)
|
||||||
|
scope.collections[node.entryKey] = entry
|
||||||
|
}
|
||||||
|
node.entryKey = ""
|
||||||
|
node.hasEntry = false
|
||||||
|
}
|
||||||
|
for segment, child := range node.children {
|
||||||
|
index.clearSubtree(scope, child)
|
||||||
|
if child.empty() {
|
||||||
|
delete(node.children, segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(node.children) == 0 {
|
||||||
|
node.children = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *collectionPolicyRetirementIndex) empty() bool {
|
||||||
|
return index == nil || !index.hasEntry && len(index.children) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneCollectionPolicyIndex(nodes []*collectionPolicyRetirementIndex, segments []pathSegment) {
|
||||||
|
for position := len(segments) - 1; position >= 0; position-- {
|
||||||
|
child := nodes[position+1]
|
||||||
|
if !child.empty() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(nodes[position].children, segments[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionPolicyHasRetirementState(policy collectionPolicy) bool {
|
||||||
|
return policy.identityFields != nil || policy.identityPresent || policy.identityLocation != nil ||
|
||||||
|
policy.recordOrder != nil || policy.recordPolicies != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assembly *assemblyAccumulator) setCollectionPolicy(destination Path, policy collectionPolicy) {
|
||||||
|
if assembly == nil || assembly.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assembly.collectionPolicyRetirement == nil {
|
||||||
|
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||||
|
}
|
||||||
|
assembly.current.collectionPolicies.set(destination, policy)
|
||||||
|
entry := assembly.current.collectionPolicies.collections[destination.String()]
|
||||||
|
if collectionPolicyHasRetirementState(entry.policy) {
|
||||||
|
assembly.collectionPolicyRetirement.set(entry)
|
||||||
|
} else {
|
||||||
|
assembly.collectionPolicyRetirement.remove(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assembly *assemblyAccumulator) retireCollectionPoliciesAtOrBelow(ancestor Path) {
|
||||||
|
if assembly == nil || assembly.current == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if assembly.collectionPolicyRetirement == nil {
|
||||||
|
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||||
|
}
|
||||||
|
assembly.collectionPolicyRetirement.retire(&assembly.current.collectionPolicies, ancestor)
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
|||||||
if len(diagnostics) != 0 {
|
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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = ©
|
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestImportSchedulingKeepsUnselectedSlotsAndScopeBoundaries(t *testing.T) {
|
||||||
|
dependent := importNodeKey{resourcePath: "a-dependent"}
|
||||||
|
external := importNodeKey{resourcePath: "external"}
|
||||||
|
prerequisite := importNodeKey{resourcePath: "z-prerequisite"}
|
||||||
|
graph := newImportInclusionGraph()
|
||||||
|
for _, key := range []importNodeKey{dependent, external, prerequisite} {
|
||||||
|
graph.addNode(importGraphNode{key: key})
|
||||||
|
}
|
||||||
|
graph.addEdge(dependent, prerequisite, Location{})
|
||||||
|
graph.addEdge(dependent, external, Location{})
|
||||||
|
graph.addEdge(external, prerequisite, Location{})
|
||||||
|
contributions := []acquiredContribution{
|
||||||
|
{identity: dependent}, {identity: external}, {identity: prerequisite},
|
||||||
|
}
|
||||||
|
scheduled := scheduleContributionSlots(contributions, []int{0, 2}, indexImportDependencies(graph))
|
||||||
|
if scheduled[0].identity != prerequisite || scheduled[1].identity != external || scheduled[2].identity != dependent {
|
||||||
|
t.Fatalf("scope schedule = %#v", scheduled)
|
||||||
|
}
|
||||||
|
if contributions[0].identity != dependent || contributions[2].identity != prerequisite {
|
||||||
|
t.Fatal("scheduling changed the input slots")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
|
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
|
||||||
names := []string{"a-base", "b-independent", "z-dependent"}
|
names := []string{"a-base", "b-independent", "z-dependent"}
|
||||||
keys := make(map[string]importNodeKey, len(names))
|
keys := make(map[string]importNodeKey, len(names))
|
||||||
@@ -33,7 +56,7 @@ func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.
|
|||||||
}
|
}
|
||||||
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
||||||
|
|
||||||
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, graph)
|
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, indexImportDependencies(graph))
|
||||||
got := make([]string, len(scheduled))
|
got := make([]string, len(scheduled))
|
||||||
for index, contribution := range scheduled {
|
for index, contribution := range scheduled {
|
||||||
got[index] = contribution.identity.resourcePath[1:]
|
got[index] = contribution.identity.resourcePath[1:]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,13 +108,14 @@ parent: true
|
|||||||
}
|
}
|
||||||
defer func() { _ = access.close() }()
|
defer func() { _ = access.close() }()
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
access: access,
|
access: access,
|
||||||
policy: &acquisitionPolicy{importLimits: defaultImportLimits()},
|
policy: &acquisitionPolicy{importLimits: defaultImportLimits()},
|
||||||
state: &acquisitionState{accounting: &importAccounting{}},
|
state: &acquisitionState{accounting: &importAccounting{sources: 1}},
|
||||||
tier: &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)},
|
tier: &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)},
|
||||||
active: make(map[resourceIdentity]int),
|
sources: sourceInfos(main.source),
|
||||||
|
active: make(map[resourceIdentity]int),
|
||||||
}
|
}
|
||||||
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
|
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry"), 0); len(diagnostics) != 0 {
|
||||||
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +129,11 @@ parent: true
|
|||||||
{path: alphaPath, destination: "/entry/collection"},
|
{path: alphaPath, destination: "/entry/collection"},
|
||||||
{path: betaPath, destination: "/entry/collection"},
|
{path: betaPath, destination: "/entry/collection"},
|
||||||
}
|
}
|
||||||
if len(state.sources) != len(wantSources) {
|
if len(state.sources) != len(wantSources)+1 {
|
||||||
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
|
t.Fatalf("sources = %#v, want %d including main", state.sources, len(wantSources)+1)
|
||||||
}
|
}
|
||||||
for index, want := range wantSources {
|
for index, want := range wantSources {
|
||||||
got := state.sources[index]
|
got := state.sources[index+1]
|
||||||
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
||||||
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]{
|
||||||
|
|||||||
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type unsupportedDynamicRoot struct {
|
||||||
|
Child unsupportedDynamicChild `yaml:"child"`
|
||||||
|
Invalid chan int `yaml:"invalid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unsupportedDynamicChild struct {
|
||||||
|
Parent *unsupportedDynamicRoot `yaml:"parent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretDynamicRoot struct {
|
||||||
|
Nested secretDynamicNested `yaml:"nested"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type secretDynamicNested struct {
|
||||||
|
Token Secret `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicTransactionConfig struct {
|
||||||
|
First any `yaml:"first"`
|
||||||
|
Second any `yaml:"second"`
|
||||||
|
Good any `yaml:"good"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDynamicDescriptorFailuresRollBackProvisionalTypes(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
result, err := testLoad[dynamicTransactionConfig](NoSource(), Options[dynamicTransactionConfig]{Definition: Definition[dynamicTransactionConfig]{
|
||||||
|
Resolve: func(config *dynamicTransactionConfig, resolution *Resolution) error {
|
||||||
|
called = true
|
||||||
|
Normalize(resolution, Keys("first"), &config.First, any((*unsupportedDynamicRoot)(nil)), "try typed nil")
|
||||||
|
Normalize(resolution, Keys("second"), &config.Second, any(&unsupportedDynamicRoot{}), "try non-nil")
|
||||||
|
Normalize(resolution, Keys("good"), &config.Good, any(map[string]string{"valid": "value"}), "continue with supported value")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !called || result != nil || !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("result = %#v, called=%v, error=%v", result, called, err)
|
||||||
|
}
|
||||||
|
paths := make(map[string]Code)
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
paths[diagnostic.Path.String()] = diagnostic.Code
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/first", "/second"} {
|
||||||
|
if paths[path] != CodeResolveInvalidValue {
|
||||||
|
t.Errorf("diagnostic at %s = %q, want safe invalid-value failure", path, paths[path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := mustSchemaFor[dynamicTransactionConfig](t)
|
||||||
|
operation := valueOperation{schema: schema}
|
||||||
|
prior, issue := operation.dynamicDescriptor(reflect.TypeFor[map[string]string](), Keys("prior"))
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatalf("prior valid dynamic descriptor issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("failure")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||||
|
t.Fatalf("unsupported dynamic descriptor issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if _, approved := operation.approvedDynamic[reflect.TypeFor[unsupportedDynamicRoot]()]; approved {
|
||||||
|
t.Fatal("unsupported dynamic descriptor was cached as eligible")
|
||||||
|
}
|
||||||
|
if schema.types[reflect.TypeFor[map[string]string]()] != prior {
|
||||||
|
t.Fatal("failed build removed a previously completed descriptor")
|
||||||
|
}
|
||||||
|
for _, current := range []reflect.Type{
|
||||||
|
reflect.TypeFor[unsupportedDynamicRoot](),
|
||||||
|
reflect.TypeFor[unsupportedDynamicChild](),
|
||||||
|
reflect.TypeFor[*unsupportedDynamicRoot](),
|
||||||
|
} {
|
||||||
|
if _, exists := schema.types[current]; exists {
|
||||||
|
t.Errorf("failed build retained provisional descriptor for %v", current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("again")); issue == nil || issue.path.String() != "/again" {
|
||||||
|
t.Fatalf("repeated failure issue = %#v", issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretSchema, schemaIssue := buildSchema(reflect.TypeFor[dynamicTransactionConfig](), testSecretBinding())
|
||||||
|
if schemaIssue != nil {
|
||||||
|
t.Fatalf("secret schema issue = %#v", schemaIssue)
|
||||||
|
}
|
||||||
|
secretOperation := valueOperation{schema: secretSchema}
|
||||||
|
if _, issue := secretOperation.dynamicDescriptor(reflect.TypeFor[secretDynamicRoot](), Keys("secret")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||||
|
t.Fatalf("dynamic secret eligibility issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if len(secretOperation.approvedDynamic) != 0 {
|
||||||
|
t.Fatalf("secret-bearing dynamic descriptor was cached as eligible: %#v", secretOperation.approvedDynamic)
|
||||||
|
}
|
||||||
|
for _, current := range []reflect.Type{reflect.TypeFor[secretDynamicRoot](), reflect.TypeFor[secretDynamicNested](), reflect.TypeFor[Secret]()} {
|
||||||
|
if _, exists := secretSchema.types[current]; exists {
|
||||||
|
t.Errorf("secret eligibility failure retained provisional descriptor for %v", current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type wideDynamicEligibilityValue struct {
|
||||||
|
Alpha string
|
||||||
|
Bravo string
|
||||||
|
Charlie string
|
||||||
|
Delta string
|
||||||
|
Echo string
|
||||||
|
Foxtrot string
|
||||||
|
Golf string
|
||||||
|
Hotel string
|
||||||
|
India string
|
||||||
|
Juliet string
|
||||||
|
Kilo string
|
||||||
|
Lima string
|
||||||
|
Mike string
|
||||||
|
November string
|
||||||
|
Oscar string
|
||||||
|
Papa string
|
||||||
|
}
|
||||||
|
|
||||||
|
type repeatedNilDynamicEligibilityConfig struct {
|
||||||
|
Values []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDynamicEligibilityIsReusedWithinProjectionOperation(t *testing.T) {
|
||||||
|
schema, schemaIssue := buildSchema(reflect.TypeFor[repeatedNilDynamicEligibilityConfig](), testSecretBinding())
|
||||||
|
if schemaIssue != nil {
|
||||||
|
t.Fatalf("schema issue = %#v", schemaIssue)
|
||||||
|
}
|
||||||
|
values := make([]any, 32)
|
||||||
|
for index := range values {
|
||||||
|
values[index] = (*wideDynamicEligibilityValue)(nil)
|
||||||
|
}
|
||||||
|
configuration := repeatedNilDynamicEligibilityConfig{Values: values}
|
||||||
|
operation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||||
|
node, issue := operation.project(reflect.ValueOf(configuration), schema.root, Path{}, 0)
|
||||||
|
if issue != nil {
|
||||||
|
t.Fatalf("repeated typed-nil projection issue = %#v", issue)
|
||||||
|
}
|
||||||
|
if len(node.Mapping) != 1 || len(node.Mapping[0].Value.Sequence) != len(values) {
|
||||||
|
t.Fatalf("projected repeated values = %#v", node)
|
||||||
|
}
|
||||||
|
for index, child := range node.Mapping[0].Value.Sequence {
|
||||||
|
if child.Kind != document.Null {
|
||||||
|
t.Errorf("projected value %d kind = %v, want null", index, child.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current := reflect.TypeFor[*wideDynamicEligibilityValue]()
|
||||||
|
if len(operation.approvedDynamic) != 1 || operation.approvedDynamic[current] == nil ||
|
||||||
|
operation.approvedDynamic[current] != schema.types[current] {
|
||||||
|
t.Fatalf("operation eligibility cache = %#v", operation.approvedDynamic)
|
||||||
|
}
|
||||||
|
otherOperation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||||
|
if len(otherOperation.approvedDynamic) != 0 {
|
||||||
|
t.Fatalf("dynamic eligibility leaked into another operation: %#v", otherOperation.approvedDynamic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,73 +7,33 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||||
if source.kind == sourceNone && context.FS == nil {
|
resolved, diagnostic := resolveFilesystemContext(source, context, main, hostToken)
|
||||||
return validateAbsentHostFilesystemContext(context)
|
|
||||||
}
|
|
||||||
if nativeContext(source, context) {
|
|
||||||
resolved, diagnostic := resolveNativeFilesystemContext(source, context)
|
|
||||||
if diagnostic != nil {
|
|
||||||
return diagnostic
|
|
||||||
}
|
|
||||||
return validateResolvedFilesystemDirectories(resolved)
|
|
||||||
}
|
|
||||||
resolved, diagnostic := resolveHostFilesystemContext(source, context, main, nil)
|
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return diagnostic
|
// Keep absent-input validation's existing diagnostic while sharing
|
||||||
|
// root/base derivation with overlay and resource-context handling.
|
||||||
|
if source.kind == sourceNone && context.FS == nil && diagnostic.Code == CodeImportOutsideRoot {
|
||||||
|
diagnostic.Message = "import base must stay inside the configuration root"
|
||||||
|
}
|
||||||
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
}
|
}
|
||||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
return diagnostic
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
|
}
|
||||||
|
if resolved.native != nil {
|
||||||
|
return resolved, nil
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(resolved.rootPath)
|
root, err := os.OpenRoot(resolved.rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return &value
|
return resolvedFilesystemContext{}, &value
|
||||||
}
|
}
|
||||||
if err := root.Close(); err != nil {
|
if err := root.Close(); err != nil {
|
||||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
return &value
|
return resolvedFilesystemContext{}, &value
|
||||||
}
|
}
|
||||||
return nil
|
return resolved, nil
|
||||||
}
|
|
||||||
|
|
||||||
func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic {
|
|
||||||
rootPath, err := canonicalExistingPath(context.Root)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
basePath := rootPath
|
|
||||||
if context.BaseDir != "" {
|
|
||||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
|
||||||
if err != nil {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !containedPath(rootPath, basePath) {
|
|
||||||
value := importDiagnostic(CodeImportOutsideRoot, "import base must stay inside the configuration root", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
for _, candidate := range []string{rootPath, basePath} {
|
|
||||||
info, err := os.Stat(candidate)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(rootPath)
|
|
||||||
if err != nil {
|
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
|
||||||
value.Primary = nil
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if err := root.Close(); err != nil {
|
|
||||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
210
internal/engine/history_budget_test.go
Normal file
210
internal/engine/history_budget_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type admittedImport struct {
|
|||||||
identity resourceIdentity
|
identity resourceIdentity
|
||||||
nodeKey importNodeKey
|
nodeKey importNodeKey
|
||||||
source SourceInfo
|
source SourceInfo
|
||||||
|
sourceIndex int
|
||||||
payload *document.Node
|
payload *document.Node
|
||||||
declarations []importDeclarationSpec
|
declarations []importDeclarationSpec
|
||||||
sequenceDeclarations []sequenceDeclarationSpec
|
sequenceDeclarations []sequenceDeclarationSpec
|
||||||
@@ -60,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 {
|
||||||
@@ -72,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
|
||||||
}
|
}
|
||||||
@@ -94,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++
|
||||||
@@ -124,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}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +166,7 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||||
})
|
})
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{})
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}, 0)
|
||||||
} else {
|
} else {
|
||||||
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||||
placementNodes := destinationWrapperNodeCount(destination)
|
placementNodes := destinationWrapperNodeCount(destination)
|
||||||
@@ -172,15 +182,15 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
destination: destination.clone(), processing: processing,
|
destination: destination.clone(), processing: processing,
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||||
})
|
})
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination)
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
state.scheduleAcquisitionScopes(&state.scope)
|
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))
|
||||||
@@ -202,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 {
|
||||||
@@ -239,14 +237,14 @@ func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path) []Diagnostic {
|
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
||||||
policies := make([]resolvedImportPolicy, len(declarations))
|
policies := make([]resolvedImportPolicy, len(declarations))
|
||||||
for index, spec := range declarations {
|
for index, spec := range declarations {
|
||||||
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
||||||
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)}}
|
||||||
}
|
}
|
||||||
@@ -280,23 +278,19 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !policy.required && len(s.sources) == sourcesBefore {
|
if !policy.required && len(s.sources) == sourcesBefore {
|
||||||
s.recordEmptyImportSelection(spec)
|
s.recordEmptyImportSelection(sourceIndex, spec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) recordEmptyImportSelection(spec importDeclarationSpec) {
|
func (s *importTraversal) recordEmptyImportSelection(sourceIndex int, spec importDeclarationSpec) {
|
||||||
for index := range s.sources {
|
// The declaring source's position survives descendant appends and optional
|
||||||
if s.sources[index].ID != spec.location.SourceID {
|
// missing-source rollback, unlike a pointer into the source slice.
|
||||||
continue
|
s.sources[sourceIndex].importSelections = append(s.sources[sourceIndex].importSelections, importSelectionEvidence{
|
||||||
}
|
path: spec.path.clone(), location: spec.location,
|
||||||
s.sources[index].importSelections = append(s.sources[index].importSelections, importSelectionEvidence{
|
selector: spec.selector, outcome: importSelectionNone,
|
||||||
path: spec.path.clone(), location: spec.location,
|
})
|
||||||
selector: spec.selector, outcome: importSelectionNone,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) admitImportSource(canonical string, identity resourceIdentity, nodeKey importNodeKey, candidate selectedImportCandidate, policy resolvedImportPolicy, destination Path, location Location) (importAdmissionResult, []Diagnostic) {
|
func (s *importTraversal) admitImportSource(canonical string, identity resourceIdentity, nodeKey importNodeKey, candidate selectedImportCandidate, policy resolvedImportPolicy, destination Path, location Location) (importAdmissionResult, []Diagnostic) {
|
||||||
@@ -311,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
|
||||||
@@ -321,6 +315,7 @@ func (s *importTraversal) admitImportSource(canonical string, identity resourceI
|
|||||||
if kind == SourceKindFilesystem {
|
if kind == SourceKindFilesystem {
|
||||||
info.namespace = s.access.token
|
info.namespace = s.access.token
|
||||||
}
|
}
|
||||||
|
sourceIndex := len(s.sources)
|
||||||
s.sources = append(s.sources, info)
|
s.sources = append(s.sources, info)
|
||||||
data, skipped, diagnostic := s.readImport(canonical, sourceID, policy.required)
|
data, skipped, diagnostic := s.readImport(canonical, sourceID, policy.required)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -369,7 +364,7 @@ func (s *importTraversal) admitImportSource(canonical string, identity resourceI
|
|||||||
}
|
}
|
||||||
return importAdmissionResult{source: admittedImport{
|
return importAdmissionResult{source: admittedImport{
|
||||||
canonical: canonical, identity: identity, nodeKey: nodeKey,
|
canonical: canonical, identity: identity, nodeKey: nodeKey,
|
||||||
source: cloneSourceInfo(info), payload: processing.payload,
|
source: cloneSourceInfo(info), sourceIndex: sourceIndex, payload: processing.payload,
|
||||||
declarations: processing.imports,
|
declarations: processing.imports,
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(processing.sequences),
|
sequenceDeclarations: cloneSequenceDeclarations(processing.sequences),
|
||||||
processing: &processing,
|
processing: &processing,
|
||||||
@@ -438,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
|
||||||
@@ -477,7 +472,7 @@ func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec impo
|
|||||||
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
|
||||||
s.stack = append(s.stack, route)
|
s.stack = append(s.stack, route)
|
||||||
diagnostics = s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, destination)
|
diagnostics = s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, destination, admitted.sourceIndex)
|
||||||
delete(s.active, admitted.identity)
|
delete(s.active, admitted.identity)
|
||||||
s.stack = s.stack[:len(s.stack)-1]
|
s.stack = s.stack[:len(s.stack)-1]
|
||||||
return diagnostics
|
return diagnostics
|
||||||
|
|||||||
@@ -2,11 +2,54 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestBroadEmptyImportSelectionsKeepDeclaringSourceOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- glob: fragments/*.yml
|
||||||
|
- path: []
|
||||||
|
`)
|
||||||
|
const fragments = 32
|
||||||
|
for index := 0; index < fragments; index++ {
|
||||||
|
writeImportFile(t, directory, fmt.Sprintf("fragments/fragment-%02d.yml", index), `configkit:
|
||||||
|
imports:
|
||||||
|
- path: missing.yml
|
||||||
|
- path: []
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
loaded, err := testLoad[map[string]any](File(mainPath), Options[map[string]any]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != fragments+1 {
|
||||||
|
t.Fatalf("source count = %d, want %d", len(sources), fragments+1)
|
||||||
|
}
|
||||||
|
for index, source := range sources {
|
||||||
|
wantPaths := []string{"/configkit/imports/0", "/configkit/imports/1"}
|
||||||
|
if index == 0 {
|
||||||
|
wantPaths = wantPaths[1:]
|
||||||
|
}
|
||||||
|
if len(source.importSelections) != len(wantPaths) {
|
||||||
|
t.Fatalf("source %s selections = %#v", source.ID, source.importSelections)
|
||||||
|
}
|
||||||
|
for selectionIndex, selection := range source.importSelections {
|
||||||
|
if selection.path.String() != wantPaths[selectionIndex] || selection.location.SourceID != source.ID ||
|
||||||
|
selection.selector != importSelectorPath || selection.outcome != importSelectionNone {
|
||||||
|
t.Errorf("source %s selection %d = %#v", source.ID, selectionIndex, selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) {
|
func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,20 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph) acquisitionScope {
|
// importDependencyIndex is built once per scheduling operation and shared by
|
||||||
|
// its scopes. Inclusion targets are grouped by importer, so local scheduling
|
||||||
|
// need not scan or reserve storage for unrelated graph edges.
|
||||||
|
type importDependencyIndex map[importNodeKey][]importNodeKey
|
||||||
|
|
||||||
|
func indexImportDependencies(graph importInclusionGraph) importDependencyIndex {
|
||||||
|
index := make(importDependencyIndex)
|
||||||
|
for _, edge := range graph.edges {
|
||||||
|
index[edge.importer] = append(index[edge.importer], edge.imported)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleAcquisitionScope(scope acquisitionScope, dependencies importDependencyIndex) acquisitionScope {
|
||||||
slots := make([]int, 0, len(scope.contributions))
|
slots := make([]int, 0, len(scope.contributions))
|
||||||
for index, contribution := range scope.contributions {
|
for index, contribution := range scope.contributions {
|
||||||
if contribution.scopeOwner != scope.owner {
|
if contribution.scopeOwner != scope.owner {
|
||||||
@@ -166,7 +179,7 @@ func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph
|
|||||||
slots = append(slots, index)
|
slots = append(slots, index)
|
||||||
}
|
}
|
||||||
scheduled := scope
|
scheduled := scope
|
||||||
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, graph)
|
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, dependencies)
|
||||||
return scheduled
|
return scheduled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +192,10 @@ func scheduleAcquisitionGroup(scopes []acquisitionScope, graph importInclusionGr
|
|||||||
for index := range slots {
|
for index := range slots {
|
||||||
slots[index] = index
|
slots[index] = index
|
||||||
}
|
}
|
||||||
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, graph)}
|
return acquisitionScope{contributions: scheduleContributionSlots(contributions, slots, indexImportDependencies(graph))}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, graph importInclusionGraph) []acquiredContribution {
|
func scheduleContributionSlots(contributions []acquiredContribution, slots []int, dependencies importDependencyIndex) []acquiredContribution {
|
||||||
if len(slots) < 2 {
|
if len(slots) < 2 {
|
||||||
return append([]acquiredContribution(nil), contributions...)
|
return append([]acquiredContribution(nil), contributions...)
|
||||||
}
|
}
|
||||||
@@ -193,12 +206,12 @@ func scheduleContributionSlots(contributions []acquiredContribution, slots []int
|
|||||||
positions[contribution.identity] = position
|
positions[contribution.identity] = position
|
||||||
keys[position] = contribution.identity.dependencyKey()
|
keys[position] = contribution.identity.dependencyKey()
|
||||||
}
|
}
|
||||||
edges := make([]dependencyEdge, 0, len(graph.edges))
|
var edges []dependencyEdge
|
||||||
for _, edge := range graph.edges {
|
for importerKey, importer := range positions {
|
||||||
importer, importerOK := positions[edge.importer]
|
for _, importedKey := range dependencies[importerKey] {
|
||||||
imported, importedOK := positions[edge.imported]
|
if imported, exists := positions[importedKey]; exists {
|
||||||
if importerOK && importedOK {
|
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
||||||
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
order, _ := scheduleDependencies(keys, edges)
|
order, _ := scheduleDependencies(keys, edges)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
108
internal/engine/import_root_panic_test.go
Normal file
108
internal/engine/import_root_panic_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -184,7 +184,7 @@ func (s *importTraversal) visitItemSource(admitted admittedImport, destination,
|
|||||||
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: admitted.nodeKey,
|
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: admitted.nodeKey,
|
||||||
processing: admitted.processing, sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
|
processing: admitted.processing, sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
|
||||||
})
|
})
|
||||||
diagnostics := s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, Path{})
|
diagnostics := s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, Path{}, admitted.sourceIndex)
|
||||||
itemScope := s.scope
|
itemScope := s.scope
|
||||||
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ type importTraversal struct {
|
|||||||
importGraph importInclusionGraph
|
importGraph importInclusionGraph
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) scheduleAcquisitionScopes(scope *acquisitionScope) {
|
func scheduleAcquisitionScopes(scope *acquisitionScope, dependencies importDependencyIndex) {
|
||||||
*scope = scheduleAcquisitionScope(*scope, s.importGraph)
|
*scope = scheduleAcquisitionScope(*scope, dependencies)
|
||||||
for index := range scope.contributions {
|
for index := range scope.contributions {
|
||||||
if pending := scope.contributions[index].pendingItem; pending != nil {
|
if pending := scope.contributions[index].pendingItem; pending != nil {
|
||||||
s.scheduleAcquisitionScopes(pending)
|
scheduleAcquisitionScopes(pending, dependencies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -1107,6 +1117,70 @@ id: item
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBroadItemScopesKeepHelperOriginsAndDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
const count = 32
|
||||||
|
var main strings.Builder
|
||||||
|
main.WriteString("configkit:\n imports:\n")
|
||||||
|
for index := count - 1; index >= 0; index-- {
|
||||||
|
name := strconv.Itoa(100 + index)
|
||||||
|
main.WriteString(" - path: item-" + name + ".yml\n destination: [items]\n placement: sequence-item\n")
|
||||||
|
writeImportFile(t, directory, "item-"+name+".yml", "configkit: {imports: [{path: helper-"+name+".yml}]}\nid: item-"+name+"\n")
|
||||||
|
writeImportFile(t, directory, "helper-"+name+".yml", "value: helper-"+name+"\n")
|
||||||
|
}
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", main.String())
|
||||||
|
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||||
|
}}}
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
t.Run(strconv.FormatBool(layered), func(t *testing.T) {
|
||||||
|
var loaded *Loaded[config]
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
loaded, err = LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[config]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
loaded, err = Load(File(mainPath), testSecretBinding(), Options[config]{Imports: true, Definition: definition})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := testLoaded(loaded)
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(result.Config.Items) != count || len(sources) != 2*count+1 {
|
||||||
|
t.Fatalf("item/source counts = %d/%d", len(result.Config.Items), len(sources))
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
for index, record := range result.Config.Items {
|
||||||
|
name := strconv.Itoa(100 + index)
|
||||||
|
if record.ID != "item-"+name || record.Value != "helper-"+name {
|
||||||
|
t.Fatalf("item %d = %#v", index, record)
|
||||||
|
}
|
||||||
|
// Acquisition remains authored (descending); final item order is
|
||||||
|
// dependency-ready (ascending), with helper evidence reprojected.
|
||||||
|
helper := sources[2*(count-index)]
|
||||||
|
field, found := metadata.Lookup(Keys("items").Index(index).Key("value"))
|
||||||
|
if !found || len(field.Origin.Locations) != 1 || field.Origin.Locations[0].SourceID != helper.ID ||
|
||||||
|
helper.Path != canonicalTestPath(t, filepath.Join(directory, "helper-"+name+".yml")) ||
|
||||||
|
helper.Destination.String() != Keys("items").Index(index).String() {
|
||||||
|
t.Fatalf("item %d helper evidence = %#v, %#v", index, field, helper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
@@ -1126,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
|
||||||
@@ -1334,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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1365,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1452,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{})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" ||
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)...)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
112
internal/engine/pointer_descriptor_test.go
Normal file
112
internal/engine/pointer_descriptor_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
101
internal/engine/projection_budget_test.go
Normal file
101
internal/engine/projection_budget_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = ¤t.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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -43,7 +42,6 @@ func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext,
|
|||||||
value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")
|
value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")
|
||||||
return resolvedFilesystemContext{}, &value
|
return resolvedFilesystemContext{}, &value
|
||||||
}
|
}
|
||||||
return resolveAbsentFilesystemContext(context)
|
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
return *existing, nil
|
return *existing, nil
|
||||||
@@ -134,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(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user