Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ec66e867 | |||
| be5cdeb502 | |||
| 1f64673d0d | |||
| 01b08e76a3 | |||
| 453da684d7 | |||
| fb6c254d38 | |||
| a71413ce67 | |||
| d66338a1c5 | |||
| 643f43ce6a | |||
| c25cf39d19 | |||
| 7d18c93339 | |||
| b1d89810ca | |||
| bb5540b62e | |||
| 80cf23e710 | |||
| ac48557535 | |||
| b4af900c10 | |||
| f132cd6468 | |||
| 75e209eb3b | |||
| 1dd94a3cc6 | |||
| d172a5e46a | |||
| 3a1b6ff355 | |||
| 68383f34de | |||
| 549fca239d | |||
| 883244936c | |||
| a8ae2e9d5e | |||
| f18582106b | |||
| d8bf6abc2b | |||
| 4bf940c579 | |||
| 4fd3bee986 | |||
| 6f6130fd12 | |||
| 3122e2f32d | |||
| e4a5ecb4be | |||
| 826062aa17 | |||
| 08f2a8f3e9 | |||
| 12d350a292 | |||
| 4fcff5f453 | |||
| 247a12bc50 | |||
| 119b42c810 | |||
| 0f9ecf12ed | |||
| 7bf2f0c687 | |||
| 8aba43da38 | |||
| 877c497325 | |||
| 08a8c25542 | |||
| ea27f17f5c | |||
| ae3408a096 | |||
| deb9aa5c69 | |||
| 2736ddfbf2 | |||
| 7889ebddfb | |||
| 4a5316b7fc | |||
| e1e94eeec7 | |||
| 176e9a5d90 | |||
| 23c1976ef1 | |||
| aab79d9508 | |||
| ecd5d5d460 | |||
| b1c278b38b | |||
| eb5a82f1f3 | |||
| b30c9f167d | |||
| 5925066d35 | |||
| 0cca633a67 | |||
| a80273433e | |||
| 320d0c5f43 | |||
| 5098b56655 | |||
| 47944d22d1 | |||
| 50199eb0af | |||
| 34cbc0d63b | |||
| 5e12b2d360 | |||
| 8dfaf44d1d | |||
| 64be8b4df5 | |||
| e585decdb2 | |||
| 6440a500f7 | |||
| d898cb193d | |||
| 557e006a4a | |||
| 3fd83b6b2b | |||
| 0fbce5d141 | |||
| ac4ce242a2 | |||
| fa6e876ab5 | |||
| c5275df1fc | |||
| f404168900 | |||
| 729c6fb5d5 | |||
| d314a269d5 | |||
| 2c3eb3308b | |||
| 6bc625b68f | |||
| b915dd776a | |||
| 9a64a65b6c | |||
| 6158e9db3b | |||
| 0d149e51f0 | |||
| bd51b9c3f2 | |||
| d05426d8a2 | |||
| 3f0efcb4ff | |||
| 19f5c2a2d5 | |||
| 455a4a355d | |||
| 8ab1568352 | |||
| 8a99e61070 | |||
| de7513d488 | |||
| 26771dc03e | |||
| fbaaaf18cd | |||
| c9574ef3c1 |
@@ -52,16 +52,6 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||
}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
result := engine.SourceInfo{
|
||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination),
|
||||
Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||
ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck),
|
||||
}
|
||||
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
|
||||
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
||||
}
|
||||
|
||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||
if values == nil {
|
||||
return nil
|
||||
@@ -76,15 +66,14 @@ func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []Impo
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
|
||||
func toEngineOverrides(values []Override) []engine.Override {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.ImportSelectionForFacade, len(values))
|
||||
result := make([]engine.Override, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = engine.ImportSelectionForFacade{
|
||||
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
|
||||
Selector: string(value.Selector), Outcome: string(value.Outcome),
|
||||
result[index] = engine.Override{
|
||||
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -92,31 +81,14 @@ func toEngineImportSelections(values []ImportSelection) []engine.ImportSelection
|
||||
|
||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||
return engine.ImportDefaults{
|
||||
Required: cloneImportDefaultBool(value.Required),
|
||||
Follow: cloneImportDefaultBool(value.Follow),
|
||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
||||
Required: value.Required,
|
||||
Follow: value.Follow,
|
||||
Hidden: value.Hidden,
|
||||
Recursive: value.Recursive,
|
||||
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 {
|
||||
if value == nil {
|
||||
return nil
|
||||
@@ -127,16 +99,6 @@ func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersi
|
||||
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||
}
|
||||
}
|
||||
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &engine.SourceVersionCheck{
|
||||
Path: toEnginePath(value.Path),
|
||||
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||
}
|
||||
}
|
||||
func fromEngineOrigin(value engine.Origin) Origin {
|
||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||
if value.Inheritance != nil {
|
||||
@@ -150,19 +112,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
|
||||
}
|
||||
return result
|
||||
}
|
||||
func toEngineOrigin(value Origin) engine.Origin {
|
||||
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||
if value.Inheritance != nil {
|
||||
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
|
||||
for index, step := range value.Inheritance {
|
||||
result.Inheritance[index] = engine.InheritanceStep{
|
||||
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
|
||||
Locations: toEngineLocations(step.Locations),
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,30 @@ applications. Consumers still own their configuration structs, defaults, semanti
|
||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||
resource checks, remote acquisition, and service initialization.
|
||||
|
||||
## Choosing a loading entry point
|
||||
|
||||
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
|
||||
[`SearchConfig`](../../discovery.go) is also a source, so its explicit
|
||||
discovery result can be passed to `Load` or used as the main input to
|
||||
[`LoadLayered`](../../layered.go).
|
||||
- Use `LoadLayered` when inputs must be composed in underlay groups before the
|
||||
main source. Each [`Input`](../../layered.go) owns its source, filesystem,
|
||||
destination, and import choice. The overlay filesystem context is selected
|
||||
independently in [`LayeredOptions`](../../layered.go).
|
||||
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||
version policy), import defaults, and explicit overrides. These do not turn
|
||||
on imports or discovery by themselves. See the
|
||||
[configuration contract](../config.md#layered-loading) and
|
||||
[discovery contract](../config.md#configuration-discovery) for exact rules.
|
||||
|
||||
The root [README example](../../README.md) shows the small `Load` path.
|
||||
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
|
||||
file and layered cases. For import-focused adoption, see the
|
||||
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||
discovery composed with sequences, see the
|
||||
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||
|
||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
||||
sanitized shapes using only ConfigKit and the Go standard library:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
|
||||
| Architecture decisions or future work | [Future features](roadmap/future.md), [remediation roadmap](roadmap/roadmap.md), and relevant ADRs listed below |
|
||||
| Code and test changes | The owning contract and tests from the map below |
|
||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||
| Release preparation | [Release procedure](release.md) |
|
||||
@@ -113,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||
```
|
||||
|
||||
Remaining optimization candidates belong to
|
||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
||||
[future performance experiments](roadmap/future.md#5-performance-experiments).
|
||||
|
||||
The completed effective-configuration diff roadmap, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `1c2610c` under
|
||||
@@ -189,7 +189,25 @@ remediation record are preserved in Git history at `0369111`, for example
|
||||
[configuration discovery](config.md#configuration-discovery); the public
|
||||
[discovery declarations](../discovery.go) own the Go API.
|
||||
|
||||
The completed consolidation proposal, implementation plan, and resolved audit
|
||||
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).
|
||||
The completed audit remediation's scope is recorded in the
|
||||
[remediation roadmap](roadmap/roadmap.md). The consolidated audit, including
|
||||
findings, evidence, and settled scope decisions, is preserved at
|
||||
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
|
||||
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
|
||||
|
||||
```sh
|
||||
git show 453da68:docs/roadmap/audit.md
|
||||
```
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
Acquire declared dependencies explicitly before entering an offline
|
||||
@@ -237,17 +255,17 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| Task | Behavioral test owners | Focused command |
|
||||
| --- | --- | --- |
|
||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
|
||||
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||
| 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` |
|
||||
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||
| 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'` |
|
||||
|
||||
@@ -7,7 +7,11 @@ This note records implementation flow and private identities for maintainers.
|
||||
## Discovery and acquisition
|
||||
|
||||
Each source's root directive is parsed into presence-aware declarations and
|
||||
resolved against load-wide defaults. Candidate selection preserves a normalized
|
||||
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
|
||||
policy, load-wide accounting, and tier-scoped repetition/namespace state;
|
||||
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
|
||||
and import admission. Candidate
|
||||
selection in `import_selection.go` preserves a normalized
|
||||
namespace path separately from canonical access identity: overlapping roots
|
||||
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||
through distinct spellings retain separate inclusion routes for admission.
|
||||
@@ -20,10 +24,34 @@ Directory traversal tracks canonical directories on the active traversal path.
|
||||
It adds an identity when descending and removes it when the frame is popped, so
|
||||
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||
not mistaken for one. This is separate from import dependency-cycle detection.
|
||||
For host sources, discovery uses rooted `Lstat` and canonical containment. Native
|
||||
sources use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows
|
||||
the capabilities and behavior of the provided `fs.FS`, without claiming a
|
||||
general sandbox guarantee.
|
||||
For host sources, discovery uses rooted `Lstat` and canonical containment.
|
||||
`filesystem_context.go` owns resolved access facts and live access setup; the
|
||||
acquisition operation closes its live handles before composition. Native sources
|
||||
use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
|
||||
capabilities and behavior of the provided `fs.FS`, without claiming a general
|
||||
sandbox guarantee. `source_context.go` derives passive resource contexts from
|
||||
resolved sources for later caller-owned reads; those records do not retain live
|
||||
acquisition handles.
|
||||
|
||||
Host and native link bindings share one iterative link-frame evaluator. It
|
||||
tracks active links for cycle detection and reuses successfully completed link
|
||||
targets while resolving the remaining path components; each binding retains its
|
||||
own namespace and target-containment rules. Directory reads distinguish an
|
||||
initial-open failure from failures after opening: only an initial missing
|
||||
directory can be treated as optional, while enumeration and close failures
|
||||
remain selection errors, including errors from the standard `fs.ReadDir`
|
||||
fallback.
|
||||
|
||||
Glob selection keeps traversal coordination in `selectGlobImportCandidates`:
|
||||
it advances literal and patterned segments, reads directories, unions and sorts
|
||||
candidates, validates final targets, then applies exclusions and requiredness.
|
||||
For each patterned directory entry, `prepareGlobEntry` handles matching and
|
||||
hidden filtering, containment, target and link inspection, intermediate-directory
|
||||
eligibility, host/native access-path selection, and candidate identity and label.
|
||||
It returns a candidate, an ordinary skip, or a diagnostic without mutating the
|
||||
selector's candidate set or traversal state. Directory entry preparation remains
|
||||
separate because its matching, absence, recursion, and traversal-state rules
|
||||
differ.
|
||||
|
||||
Acquisition remains authored: declarations are visited in source order and each
|
||||
selected candidate set in namespace-path lexical order. This preserves source
|
||||
@@ -64,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
|
||||
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
|
||||
strict-scope graph. Applicable collection declarations are gathered for the
|
||||
scope before peer composition, including identity inherited from lower tiers.
|
||||
@@ -78,10 +112,14 @@ mapping order in its output.
|
||||
These scopes do not flatten into one global graph, and independent precedence
|
||||
tiers remain separate.
|
||||
|
||||
The main owners are [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
The owners are [`acquisition.go`](../../internal/engine/acquisition.go),
|
||||
[`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||
[`import_graph.go`](../../internal/engine/import_graph.go),
|
||||
[`import_projection.go`](../../internal/engine/import_projection.go),
|
||||
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
|
||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
||||
Their focused behavior is covered by import-selection, scheduler, and scheduling
|
||||
integration tests under `internal/engine`.
|
||||
Their focused behavior is covered by import-selection, acquisition, scheduler,
|
||||
and scheduling integration tests under `internal/engine`; see the
|
||||
[development test map](../development.md#task-to-test-map).
|
||||
|
||||
@@ -20,14 +20,19 @@ Implemented component owners:
|
||||
inheritance and decoding, before application resolution. The engine also owns
|
||||
shared source-version checking with sanitized declaration evidence.
|
||||
Explicit import discovery supports declared paths, directory searches, and
|
||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
||||
root facade without changing acquisition order. Import and inheritance
|
||||
resolution share dependency-ready scheduling within their composition scopes.
|
||||
globs; root-owned defaults are converted to the engine's single defaults input
|
||||
and snapshotted during load preflight before acquisition, while source-local
|
||||
empty-selection evidence crosses the facade without changing acquisition
|
||||
order. The root constructs placement directly on engine options and inputs
|
||||
using explicit enum conversion; public types and fields remain root-owned.
|
||||
Import and inheritance resolution share dependency-ready scheduling within
|
||||
their composition scopes.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
provenance/history, passive source-context retention, secret resolution and
|
||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
||||
bootstrap, operation-local secret failure-location attribution, safe
|
||||
inspection rendering, and effective-value comparison over
|
||||
frozen inspection snapshots. It never imports the root package.
|
||||
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
||||
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
||||
@@ -87,3 +92,69 @@ operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-disco
|
||||
records explicit main-file discovery. The [import internals](imports.md)
|
||||
document candidate and namespace identities, discovery and scheduling, and the
|
||||
bounded algorithms.
|
||||
|
||||
## Implementation flow
|
||||
|
||||
The root facade converts caller-owned values into engine-owned requests. Its
|
||||
`ImportDefaults` conversion maps fields without taking ownership; engine load
|
||||
preflight snapshots the defaults once before source acquisition or callbacks.
|
||||
Ordinary and grouped loading then carry that read-only value through their
|
||||
normalized `loadRequest` and acquisition policy, while declaration resolution
|
||||
copies values needed by its independently owned effective policy. Placement is
|
||||
constructed directly on the engine's options and input records after explicit
|
||||
root-to-engine enum conversion. See
|
||||
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
|
||||
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
||||
version, default-import, and override policy stays distinct from each input's
|
||||
source, filesystem, destination, and import settings.
|
||||
|
||||
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
||||
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
||||
input is acquired under that policy; grouped requests retain acquired underlay
|
||||
scopes until their tier is composed in
|
||||
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
|
||||
share one assembly handoff that applies collection declarations and projects
|
||||
only that tier's source/version evidence; the caller keeps main-version
|
||||
protection and its projection explicit. Live
|
||||
filesystem access handles are confined to acquisition and closed there. Resolved
|
||||
filesystem facts used to authorize access are separate from passively retained
|
||||
resource contexts. Non-import validation retains those facts on `assembledInput`
|
||||
for passive-context construction and ordinary-overlay handling: see
|
||||
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
|
||||
[`source_context.go`](../../internal/engine/source_context.go).
|
||||
|
||||
Import responsibilities are split across candidate selection and path
|
||||
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
source reading and traversal admission in
|
||||
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
|
||||
and projection of acquired evidence into final source coordinates in
|
||||
[`import_projection.go`](../../internal/engine/import_projection.go). The
|
||||
[import internals](imports.md) page describes the boundaries and identities.
|
||||
|
||||
After acquisition handles are closed, grouped composition and inheritance run on
|
||||
the retained ordered documents. `inheritance.go` coordinates traversal and
|
||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
|
||||
dependency-ready templates;
|
||||
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
|
||||
prepares child and parent inputs,
|
||||
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
|
||||
returns the combined value with its origin, policy, sequence, and diagnostic
|
||||
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
|
||||
accounts for and finalizes the result. Projection and schema work have separate
|
||||
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
|
||||
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
|
||||
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
|
||||
descriptor caching and type policy, and
|
||||
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
|
||||
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
||||
those descriptors for typed decoding.
|
||||
|
||||
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
|
||||
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
||||
discovery, and destination tests; inheritance by the inheritance test files;
|
||||
reflection behavior by `schema_test.go`, `projection_test.go`, and
|
||||
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
||||
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
|
||||
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
|
||||
```
|
||||
|
||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
||||
The module-selected executable is ReleaseKit v0.4.2. Use the built executable
|
||||
for publication to preserve its exit status and publication-state diagnostics.
|
||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||
|
||||
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.
|
||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||
validation toolchain. Retire completed planning and measurement records into
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
# Internal Complexity Refactoring Roadmap
|
||||
|
||||
Status: proposed target state. This roadmap defines a behavior-preserving
|
||||
refactor; a later implementation plan will define stages and exact private
|
||||
signatures and file names.
|
||||
|
||||
## Purpose
|
||||
|
||||
Make ConfigKit's import, inheritance, and projection implementation easier to
|
||||
understand, review, and change while preserving its public APIs and configuration
|
||||
behavior. Concentrate on functions that coordinate several responsibilities and
|
||||
carry substantial shared state.
|
||||
|
||||
The target is clearer responsibility and ownership. Adding a few well-named
|
||||
helpers, private records, or focused files can serve that target even when total
|
||||
line count increases. Complexity measurements help locate candidates; they do
|
||||
not establish function-size limits, score thresholds, or release gates.
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns package and ownership
|
||||
constraints. The [configuration contract](../config.md) and exported Go comments
|
||||
remain authoritative for consumer behavior.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
Reviewed baseline: `7a83704f43ef9870db33661231cdff5d6ee502f7`.
|
||||
|
||||
The root facade delegates to a cohesive private engine, supported by the ordered
|
||||
document representation and YAML adapter. Most functions are small. The
|
||||
complexity audit identified these concentrated maintenance costs:
|
||||
|
||||
| Area | Current implementation | Required improvement |
|
||||
| --- | --- | --- |
|
||||
| Imports | `imports.go` spans acquisition, item-local composition, and evidence projection. Private loading variants pass long positional argument lists. Source admission and directory selection contain substantial mixed control flow. | Give import entry points explicit, cohesive inputs and separate the responsibilities of acquisition, scope assembly, and evidence projection. |
|
||||
| Inheritance | `applyInheritanceScopeTemplates` coordinates payload preparation, parent combination, policy ownership, origins, history, and accounting in one executor. | Keep one readable executor with focused operations and explicit state handoffs. |
|
||||
| Projection | `valueOperation.project` combines type dispatch with struct flattening, map and sequence traversal, scalar conversion, sensitivity, and accounting. | Retain clear dispatch while moving substantial value-kind handling into focused helpers. |
|
||||
|
||||
The implemented inventory belongs to the
|
||||
[component overview](../internal/overview.md). Existing import flow and scheduling
|
||||
are described in [import internals](../internal/imports.md).
|
||||
|
||||
## Target Architecture
|
||||
|
||||
The refactor stays within `internal/engine`. The root facade, `internal/document`,
|
||||
and `internal/yaml` retain their existing responsibilities and dependency
|
||||
direction. Focused files and ordinary private functions express the new internal
|
||||
organization. Package extraction is outside this roadmap's selected design.
|
||||
|
||||
`executeLoadRequest` remains the visible coordinator of the shared load lifecycle.
|
||||
Its phase order should remain easy to follow. Helpers expose meaningful work and
|
||||
results rather than distributing that order across a generic execution framework.
|
||||
|
||||
Existing shared mechanisms remain shared: recursive mapping combination,
|
||||
identified-sequence operations, dependency-ready scheduling, schema descriptors,
|
||||
and projection/accounting state. New helper boundaries make their ownership and
|
||||
use clearer without creating parallel implementations.
|
||||
|
||||
### Imports: Cohesive Inputs And Scope Ownership
|
||||
|
||||
Import loading has a clear private entry surface using named request/context
|
||||
fields for related inputs. Reuse the existing load-request, acquisition-scope,
|
||||
and traversal models where they fit. Consolidate forwarding variants whose only
|
||||
purpose is passing another optional positional argument through the chain.
|
||||
Retain convenience helpers when they express a useful, distinct operation.
|
||||
|
||||
Keep these state lifetimes identifiable:
|
||||
|
||||
- Load-wide state: accounting, shared inclusion/repetition information,
|
||||
namespaces, and source-version processing.
|
||||
- Acquisition requests: source and filesystem context, placement, declarations,
|
||||
applicable policy, and the relevant assembly handoff.
|
||||
- Composition scopes: peer contributions, item-local helpers, record ownership,
|
||||
and pending declarations.
|
||||
- Evidence: physical source records, effective destinations, origins, and retained
|
||||
history projections.
|
||||
|
||||
Group acquisition, pending-item assembly, and evidence projection in focused
|
||||
files. A caller should be able to identify which operation acquires resources,
|
||||
which composes already acquired content, and which projects retained evidence.
|
||||
Shared mutable state has a visible owner; a request record need not duplicate
|
||||
state already held by that owner.
|
||||
|
||||
Within dense admission, directory-selection, and declaration-parsing routines,
|
||||
extract coherent operations where that reduces nested, mixed responsibilities.
|
||||
Preserve the existing selection algorithms and host/native filesystem behavior.
|
||||
Resource ownership and closure remain explicit across success and failure paths.
|
||||
|
||||
The resulting flow preserves the distinction among acquisition order, strict
|
||||
scope scheduling, and precedence. Completing a helper scope before placing its
|
||||
item remains part of the same import flow. Ordinary and layered loading continue
|
||||
to share the applicable mechanisms.
|
||||
|
||||
### Inheritance: One Executor With Explicit Phases
|
||||
|
||||
Keep the mapping and identified-sequence adapters and their shared template
|
||||
executor. The executor coordinates focused operations for:
|
||||
|
||||
- Preparing child payload, directives, and destination-local state.
|
||||
- Preparing the resolved parent payload and applicable inherited policy.
|
||||
- Combining values through the existing mapping and sequence mechanisms.
|
||||
- Producing policy, origin, replacement, and replay evidence from that combination.
|
||||
- Accounting for expansion and retained history under the existing budgets.
|
||||
|
||||
These are responsibility boundaries, not a required helper count or a new
|
||||
scheduler. Small private result records are appropriate when related outputs
|
||||
otherwise travel through long argument lists or implicit mutations.
|
||||
|
||||
Authored templates, resolved values, scoped policy, and inspection evidence remain
|
||||
distinguishable. Immutable borrowed inputs and owned mutable results have clear
|
||||
lifetimes. Nested instructions remain available for destination-local evaluation.
|
||||
For identified-record inheritance, the child's own identity fields and origins
|
||||
remain intact; the parent's identity fields are excluded from inherited payload
|
||||
and its history, as the current contract requires.
|
||||
|
||||
One sequence-operation result continues to drive value construction, record
|
||||
ownership, origins, and historical position projection. Helpers consume that
|
||||
evidence directly. Scheduling remains dependency-ready, enclosing relationships
|
||||
resolve before nested ones, and assembled sequence order remains unchanged.
|
||||
|
||||
### Projection: Explicit Value-Kind Handlers
|
||||
|
||||
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
|
||||
the owner of per-operation traversal and accounting state. The central dispatch
|
||||
clearly selects the appropriate handling for pointers, dynamic values, structs,
|
||||
maps, sequences, and scalars.
|
||||
|
||||
Substantial struct, map, and sequence work lives in focused handlers. Struct
|
||||
projection retains declaration order, inline flattening, collision diagnostics,
|
||||
and sensitivity propagation. Map projection retains deterministic key ordering.
|
||||
Scalar conversion retains the existing number, duration, text-codec, and secret
|
||||
reference representations.
|
||||
|
||||
Depth and node accounting retain their existing meaning, including flattened
|
||||
containers. Cycle tracking stays active through recursive child traversal and
|
||||
is balanced on error paths as well as success. Passing state through a helper
|
||||
does not introduce another full-tree projection or an unnecessary ownership copy.
|
||||
|
||||
Cloning and source-value construction remain distinct operations with their
|
||||
existing semantics. Reuse small helpers when the semantics coincide; keep the
|
||||
projection refactor centered on making the existing traversal readable.
|
||||
|
||||
## Behavior And Scope Preservation
|
||||
|
||||
The implementation preserves:
|
||||
|
||||
- Root-owned public types, signatures, supported import paths, and facade boundaries.
|
||||
- Supported source forms, opt-ins, import controls, placement, acquisition and
|
||||
composition ordering, and source/version evidence.
|
||||
- Strict peer composition, precedence operations, identity agreement and lifetime,
|
||||
record ownership, and destination-local inheritance.
|
||||
- The load lifecycle, including typed defaults, post-decode CLI assignment,
|
||||
recorded application resolution, and final validation.
|
||||
- Explicit presence, provenance, history, sensitivity, resource contexts, and
|
||||
detached publication/query results.
|
||||
- Established diagnostic codes, semantic paths, source coordinates, ordering,
|
||||
resource lifetime, and existing limit behavior.
|
||||
|
||||
The owning contracts define the extent of these obligations. Incidental private
|
||||
call sequences, exact helper names, and allocation counts do not acquire new
|
||||
guarantees through this roadmap. The work adds no consumer options, configuration
|
||||
syntax, dependencies, or complexity-based gates.
|
||||
|
||||
## Relationship To Remediation And Other Work
|
||||
|
||||
The completed remediation record at `ea46001:docs/roadmap/remediation.md`
|
||||
documents the identified correctness and algorithmic efficiency findings.
|
||||
Several overlap these files, particularly item-scope application matching and
|
||||
inheritance policy localization.
|
||||
|
||||
Use the documented contract and completed remediation as the behavioral baseline.
|
||||
Keep corrective changes reviewable under their findings, and retain their
|
||||
regressions through this refactor. The later implementation plan should coordinate
|
||||
overlapping work and select its starting revision rather than duplicating the
|
||||
remediation plan here. These structural changes do not close a finding merely
|
||||
because its function moved or became smaller.
|
||||
|
||||
[Configuration discovery](../config.md#configuration-discovery) is implemented.
|
||||
Import cleanup should preserve its source-acquisition boundary and behavior as
|
||||
part of this refactor.
|
||||
|
||||
## Work Needed And Validation
|
||||
|
||||
Implementation needs to consolidate import inputs and adapters, separate import
|
||||
responsibilities, extract inheritance operations with explicit outputs, and
|
||||
decompose projection handling. Remove superseded private plumbing as its callers
|
||||
migrate. Review the resulting call flow for repeated traversals, policy scans,
|
||||
copies, or hidden state transitions introduced by extraction.
|
||||
|
||||
Reuse the existing behavioral suites under the
|
||||
[development guide](../development.md) and [testing policy](../policy/testing.md):
|
||||
|
||||
- Imports: selection, scheduling, source/version evidence, placement, helper scopes,
|
||||
ordinary/layered equivalence, filesystem failures, and resource lifetime.
|
||||
- Inheritance: mapping and record relationships, nested destination resolution,
|
||||
cancellation and replacement, policy ownership, history, sensitivity, and
|
||||
independent loads.
|
||||
- Projection: schema/decoding agreement, inline fields, text codecs, nil/empty
|
||||
values, cycles and budgets, secrets, and detached results.
|
||||
|
||||
Add focused regressions only where a consequential handoff risk lacks coverage.
|
||||
Tests should continue to describe behavior as private functions and files move.
|
||||
Use the existing everyday check and applicable ownership race checks during
|
||||
implementation. Complexity measurements can inform review; acceptance does not
|
||||
depend on numerical scores or a new benchmark campaign.
|
||||
|
||||
Update the component overview and import internals to describe the implemented
|
||||
ownership and file responsibilities when the refactor lands. Update contributor
|
||||
navigation and test-owner references where moves affect them. Public behavior
|
||||
continues to be defined by its current canonical owners.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Import callers pass cohesive named inputs, with clearly owned load-wide and
|
||||
scope-local state and fewer redundant forwarding variants.
|
||||
- Import acquisition, item assembly, and evidence projection are easy to locate
|
||||
and follow across focused private files.
|
||||
- Inheritance orchestration exposes value, policy, evidence, and accounting work
|
||||
without requiring readers to reconstruct their interaction from one large body.
|
||||
- Projection dispatch and substantial value-kind handlers have clear boundaries
|
||||
and share the original operation state correctly.
|
||||
- Existing APIs and contracted behavior, including completed remediation, remain
|
||||
covered by the relevant behavioral and ownership checks.
|
||||
- Internal documentation matches the resulting implementation.
|
||||
@@ -1,12 +1,20 @@
|
||||
# Potential Future Features
|
||||
# Potential Future Work
|
||||
|
||||
This document lists potential enhancements for consideration in future ConfigKit
|
||||
releases. Entries are not accepted contracts, implementation plans, or release
|
||||
commitments. Adopting a candidate requires an explicit design decision and a
|
||||
separate plan. Significant architectural exclusions and their rationale belong
|
||||
This document lists potential enhancements and maintenance work for consideration
|
||||
in future ConfigKit releases. Entries are not accepted contracts, implementation
|
||||
plans, or release commitments. Adopting a candidate requires an explicit design
|
||||
decision and a separate plan. Significant architectural exclusions and their rationale belong
|
||||
in ADRs; this document does not maintain a list of rejected proposals.
|
||||
|
||||
## 1. Recursive body merging for identified records
|
||||
## 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
|
||||
[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
|
||||
implementation plan; this entry does not alter current upsert behavior.
|
||||
|
||||
## 2. Required version declarations for selected sources
|
||||
## 4. Required version declarations for selected sources
|
||||
|
||||
Status: deferred. PromptKit requested a way to require version declarations on
|
||||
a selected subset of source files while permitting other fragments to omit them.
|
||||
@@ -56,7 +64,7 @@ compatible version or replace final configuration validation.
|
||||
behavior for absent optional inputs and preserve source-local diagnostics,
|
||||
inspection evidence, and existing sensitivity handling.
|
||||
|
||||
## 3. Performance experiments
|
||||
## 5. Performance experiments
|
||||
|
||||
Status: deferred pending evidence. The completed performance audit identified
|
||||
four internal optimization candidates for separate investigation:
|
||||
|
||||
296
docs/roadmap/implementation.md
Normal file
296
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Import Discovery And Facade Simplification Implementation Plan
|
||||
|
||||
Status: complete.
|
||||
Baseline: `bb5540b62e382106d59678e2edb7655acfd5c960`.
|
||||
Audience: a Luna coding agent implementing the stages sequentially.
|
||||
|
||||
## Scope And Execution
|
||||
|
||||
Implement **C03** and **F07** from the
|
||||
[internal-boundary roadmap](internal-boundaries.md). That document owns the target
|
||||
state and preservation requirements; this plan owns concrete private choices,
|
||||
steps, validation, and progress. The preceding 29-finding remediation sprint is
|
||||
complete. Its implementation record remains at
|
||||
`bb5540b:docs/roadmap/implementation.md`; this plan does not reopen it.
|
||||
|
||||
Read `AGENTS.md`, [agent policy](../policy/agents.md), the [development
|
||||
guide](../development.md), and applicable architecture, testing, and documentation
|
||||
policies. Use graph-first discovery and verify actual callers before deleting
|
||||
helpers. Preserve unrelated work.
|
||||
|
||||
This is one end-to-end sprint. Intermediate stages may fail to compile, especially
|
||||
the coordinated engine/facade cutover in Stages 2–3. Do not create adapters,
|
||||
aliases, duplicate paths, or extra abstractions solely to make intermediate
|
||||
stages green. Run focused checks once their affected surface is buildable; record
|
||||
pending checks and finish them before final acceptance. Commit focused nonempty
|
||||
units under repository policy and record stage status, actual validation, and
|
||||
known commit identifiers here. Completion requires the integrated result, not
|
||||
just checked-off stages.
|
||||
|
||||
### Boundaries
|
||||
|
||||
- Preserve public root-owned declarations and signatures, YAML syntax, loading
|
||||
semantics, ordinary/layered differences, callback order, diagnostics, source
|
||||
identity, inspection, confidentiality, and ownership.
|
||||
- Preserve glob literal-versus-discovered rules and directory/glob differences.
|
||||
Keep completed-link reuse, directory failure phases, containment, and existing
|
||||
accounting. This is not a discovery algorithm or permission-policy change.
|
||||
- Keep `resolvedImportPolicy`, registration compilation, normalized request
|
||||
responsibilities, and conversions with real ownership or domain meaning.
|
||||
Simplification is limited to defaults transport and placement construction.
|
||||
- Add no dependencies, caller controls, caches, limits, background work, release
|
||||
gates, or performance promises. Do not publish a release, update consumers, or
|
||||
revise accepted ADR decisions.
|
||||
|
||||
### Selected Ownership Model
|
||||
|
||||
Use the existing engine `ImportDefaults` name and its exported `Required`,
|
||||
`Follow`, `Hidden`, `Recursive`, and `Pattern` fields as the sole engine
|
||||
input-defaults type. Root `ImportDefaults` stays separately defined. Root
|
||||
conversion maps the fields without cloning them; the public loading wrappers
|
||||
immediately enter engine preflight. `loadSettings` takes the per-load snapshot
|
||||
before acquisition or callbacks, as it already does. The normalized request and
|
||||
acquisition policy then borrow that load-owned read-only value.
|
||||
|
||||
Declaration resolution reads the owned defaults and retains its existing copies
|
||||
of selected pattern/exclusion slices and destinations in `resolvedImportPolicy`.
|
||||
This separates presence-aware load input from independently owned effective
|
||||
declaration policy. No public copy-count or thread-safety guarantee follows.
|
||||
|
||||
For placement, expose the engine's existing enum as `PlacementMode uint8` with
|
||||
`PlacementModeMapping` and `PlacementModeSequenceItem`, and expose placement
|
||||
fields on engine input/options records for direct construction. These remain
|
||||
internal-package implementation surfaces. Root types stay root-owned; direct
|
||||
casts preserve unknown values for existing preflight validation.
|
||||
|
||||
### Validation Conventions
|
||||
|
||||
Run from the repository root with an installed compatible Go toolchain and
|
||||
dependencies already available. Use `GOTOOLCHAIN=local`, `GOPROXY=off`, and
|
||||
`GOTELEMETRY=off`. Keep disposable artifacts outside the repository and do not
|
||||
change dependencies for tooling. Focused expressions identify behavior owners;
|
||||
include a newly named regression explicitly if needed. Reuse unchanged successful
|
||||
checks. No timing, allocation, exact syscall, or helper-shape assertions are
|
||||
acceptance criteria.
|
||||
|
||||
## Stage 1 — Extract Patterned Glob Entry Preparation
|
||||
|
||||
Finding: **C03**. Owners: `internal/engine/glob.go`, existing glob selection,
|
||||
link, and directory failure tests. Status: **complete**. Validation passed:
|
||||
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'` and `git diff --check`.
|
||||
|
||||
1. Add `prepareGlobEntry` beside the selector. Take the filesystem access,
|
||||
base path, parent `selectedImportCandidate`, `fs.DirEntry`, pattern segment,
|
||||
`last` flag, and `resolvedImportPolicy`. Return a candidate, a boolean meaning
|
||||
selected versus ordinary skip, and a nullable `*Diagnostic`. Use the parent's
|
||||
existing `site`, which is propagated from the selector's root candidate.
|
||||
No candidate-set, accounting, or traversal-state mutation belongs here.
|
||||
2. Move only the patterned branch's per-entry body into this helper. Preserve
|
||||
its order: `path.Match`; match/hidden filtering; lexical/access path creation
|
||||
and containment; `importEntryTarget` and existing diagnostic translation;
|
||||
follow filtering; intermediate-directory eligibility; host/native physical
|
||||
path selection and canonicalization fallback; candidate label/site creation.
|
||||
Return existing safe codes, paths, and sites. Do not turn inspection errors
|
||||
into skips or skip validation merely because exclusion will later match.
|
||||
3. Replace that inline body with the helper call and candidate insertion.
|
||||
Leave pattern validation, `explicitGlobCandidate`, directory enumeration,
|
||||
per-segment union/sorting, final-file validation, exclusions, requiredness,
|
||||
and source-admission timing in the selector.
|
||||
4. Keep directory entry preparation separate. Reuse existing filesystem/link
|
||||
primitives; do not introduce a generic entry strategy or unify differing
|
||||
missing-target, literal-link, filtering, or recursion rules. Remove imports
|
||||
only when genuinely unused after extraction.
|
||||
|
||||
Acceptance: the selector now expresses traversal without embedding preparation
|
||||
details. Reuse `glob_selection_test.go`, `glob_test.go`, filesystem-link tests,
|
||||
and the preceding sprint's directory-phase regressions. They must still cover
|
||||
literal hidden/link components versus wildcard filtering, aliases and native
|
||||
spelling, escaping/cyclic links, matched-directory errors before exclusion,
|
||||
selection order, and bounded accounting. Add a behavioral case only for a gap
|
||||
identified during extraction, not a unit test asserting the new helper exists.
|
||||
|
||||
Focused check:
|
||||
`go test ./internal/engine -run 'Glob|Directory|Link|Filesystem|Resource'`.
|
||||
|
||||
## Stage 2 — Consolidate The Engine Defaults Representation
|
||||
|
||||
Finding: **F07**, engine representation. Owners: `internal/engine/import_spec.go`,
|
||||
`load.go`, `layered.go`, `load_request.go`, `acquisition.go`, `options.go`, and
|
||||
their callers/fixtures. Status: **complete**. Validation passed:
|
||||
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and `git diff --check`. Root facade callers remain pending Stage 3.
|
||||
|
||||
1. Retain engine `ImportDefaults` with its existing five exported fields.
|
||||
Remove private `importDefaults`, without a compatibility alias. Update
|
||||
engine option/request/acquisition fields, helper signatures, readers, and
|
||||
fixture literals to use the canonical type and exported default-field names.
|
||||
Private containing fields such as `loadRequest.importDefaults` can keep their
|
||||
names. Do not rename the distinct fields of `importDeclarationSpec` or
|
||||
`resolvedImportPolicy` just because they have similar meanings.
|
||||
2. Expose `ImportDefaults ImportDefaults` on engine `Options[T]` and
|
||||
`LayeredOptions[T]`. Remove `OptionsWithImportDefaults`,
|
||||
`LayeredOptionsWithImportDefaults`, and `privateImportDefaults`. Stage 3 changes
|
||||
root callers; a temporary compile failure is preferable to an interim bridge.
|
||||
3. Adapt `snapshotImportDefaults` to the canonical type, retaining nil boolean
|
||||
pointers and nil versus non-nil empty pattern. Keep `cloneBool` and
|
||||
`cloneStrings` where they still have real consumers. Keep current snapshot
|
||||
call sites until Stage 3 establishes their final ownership placement.
|
||||
4. Keep effective declaration policy resolution and validation semantics intact:
|
||||
explicit false beats defaults, each control resolves independently, and
|
||||
selector-specific applicability and depth checks stay unchanged. Do not
|
||||
combine defaults with resolved policy or registration compilation.
|
||||
|
||||
Acceptance: one engine input-defaults type reaches every production path and
|
||||
fixture; no equivalent private record or alias remains. Inspect ordinary,
|
||||
layered, transitive-import, directory/glob, and test-helper call sites. The root
|
||||
cutover and removal of redundant snapshots are explicitly pending until Stage 3.
|
||||
|
||||
Run the import/default focused tests after Stage 3 if the root cutover prevents
|
||||
building the package here; do not report that deferred check as passed.
|
||||
|
||||
## Stage 3 — Simplify Facade Defaults Construction And Snapshot Ownership
|
||||
|
||||
Finding: **F07**, defaults handoff. Owners: root `conversion.go`, `lifecycle.go`,
|
||||
`layered.go`; engine defaults/preflight/coordinator owners; `facade_import_defaults_test.go`
|
||||
and `internal/engine/import_spec_test.go`. Status: **complete**. Validation passed:
|
||||
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and `git diff --check`.
|
||||
|
||||
1. Set engine `ImportDefaults` fields directly in root `toEngineOptions` and
|
||||
`toEngineLayeredOptions`. Retain `toEngineImportDefaults` as the explicit
|
||||
root-to-engine type mapping, but map its booleans/pattern without copies.
|
||||
Remove root `cloneImportDefaultBool` and `cloneImportDefaultStrings` after
|
||||
confirming no other caller. Keep public root types and declaration contracts.
|
||||
2. Keep `snapshotImportDefaults` in `loadSettings` as the load-entry ownership
|
||||
operation for both APIs. Remove repeated snapshots in engine `LoadLayered`
|
||||
request construction, `executeLoadRequest` acquisition-policy construction,
|
||||
and `resolveImportPolicy`. Carry the owned defaults read-only thereafter.
|
||||
Review all callers of those helpers, including test setup; direct declaration
|
||||
resolution can read its input because it returns its own concrete policy.
|
||||
3. Keep per-policy copies of the selected pattern, exclusions, and destination.
|
||||
Do not borrow caller-owned storage beyond preflight, mutate the shared owned
|
||||
defaults, or remove copying from unrelated option/record paths. Each new load
|
||||
snapshots its own current inputs; add no reuse cache or ownership flag.
|
||||
4. Replace the private conversion-snapshot fragment in
|
||||
`TestImportDefaultsPreserveEmptyPatternAndRequiredness`, which converts options,
|
||||
mutates inputs, and calls engine `Load`. It should no longer demand a snapshot
|
||||
from a private conversion helper. Keep that test's public nil/empty,
|
||||
requiredness, and inspection-evidence checks.
|
||||
5. Protect the public snapshot contract with a compact ordinary/layered table:
|
||||
use a main document with a version and imports, enable
|
||||
`SourceVersionModeIfPresent`, and use a configuration type with version and
|
||||
imported-value fields. In the main version-check callback, mutate the caller's
|
||||
recursive default from true to false and its existing pattern element from
|
||||
`*.cfg` to `*.skip`. This callback runs after preflight but before imported
|
||||
discovery. Verify the original recursive/pattern selection still supplies a
|
||||
value from a nested imported file. Use existing fixture helpers and no
|
||||
concurrent mutation. A subsequent load with those changed defaults must omit
|
||||
that imported contribution rather than reuse the prior snapshot. Keep fixtures
|
||||
reusable or create fresh readers for each load.
|
||||
6. Reuse or extend the small snapshot/policy tests for all four optional booleans,
|
||||
explicit false, pattern mutation, nil, and non-nil empty pattern. Preserve
|
||||
existing invalid-pattern-before-read/hook tests and declaration override,
|
||||
transitive, underlay, and selected-overlay coverage. Do not assert exact copy
|
||||
counts, pointer identities, or the private conversion boundary as public policy.
|
||||
|
||||
Acceptance: the engine/facade cutover builds; public defaults remain detached
|
||||
before acquisition/callbacks, reused loads remain independent, and only genuine
|
||||
ownership copies remain. Confirm the absence of full defaults snapshots at the
|
||||
three removed downstream sites by source review, not production instrumentation.
|
||||
|
||||
Focused checks:
|
||||
`go test ./internal/engine -run 'Import|Glob|Directory|SourceVersion'` and
|
||||
`go test . -run 'ImportDefaults|FacadeImport|SourceVersion'`.
|
||||
|
||||
## Stage 4 — Construct Placement Directly Across The Facade
|
||||
|
||||
Finding: **F07**, placement handoff. Owners: engine `destination.go`, `load.go`,
|
||||
`layered.go`, normalized input and placement consumers; root `lifecycle.go`,
|
||||
`layered.go`, and placement fixtures. Status: **complete**. Validation passed:
|
||||
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`, `env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./...`, and `git diff --check`.
|
||||
|
||||
1. Rename the existing engine enum and its constants to `PlacementMode`,
|
||||
`PlacementModeMapping`, and `PlacementModeSequenceItem`, preserving underlying
|
||||
`uint8` values and zero semantics. Use the same type throughout engine
|
||||
placement parsing, normalization, acquisition, source evidence, and version
|
||||
projection. Do not add a second raw-mode record or type alias.
|
||||
2. Expose engine `Options[T].Destination` and `.PlacementMode`, and
|
||||
`Input.PlacementMode`. Normalized `loadInput` and other private owner fields
|
||||
can retain their names. Populate the normalized input from those fields.
|
||||
Remove `OptionsWithPlacement` and `InputWithPlacement` and migrate all callers,
|
||||
including the direct engine construction in root `records_test.go`.
|
||||
3. In root `toEngineOptions` and `toEngineInput`, set destination using the existing
|
||||
`toEnginePath` conversion and placement using
|
||||
`engine.PlacementMode(options.PlacementMode)` or its input equivalent.
|
||||
Preserve invalid enum values for `normalizeInput` to diagnose before reads.
|
||||
Do not add validation/defaulting to the facade or a new setter/cast function.
|
||||
4. Preserve normalization of literal/parsed destination keys, root-item rejection,
|
||||
wrapper accounting, mounted/item source coordinates, and version projection.
|
||||
Root `Path` is immutable and `toEnginePath` carries its private engine value;
|
||||
keep normalization and actual mutable ownership copies. Do not redesign path
|
||||
conversion or add redundant clones solely to replace the deleted decorator.
|
||||
5. Leave source-result transport such as `SourceInfoPlacementModeForFacade`,
|
||||
source-version conversion, sequence registration preparation, and opaque
|
||||
records alone. They are not input setters targeted by this step. Adapt tests
|
||||
mechanically to new engine construction without weakening public assertions.
|
||||
|
||||
Acceptance: both public loading APIs retain zero/mapping and sequence-item
|
||||
placement, errors before input consumption, final values, source metadata, and
|
||||
history. Reuse the public placement tests plus destination/accounting,
|
||||
item-import, mounted-version, and invalid-option cases. Root signatures still
|
||||
contain no engine types, and engine exports remain below the internal boundary.
|
||||
|
||||
Focused checks:
|
||||
`go test ./internal/engine -run 'Destination|Placement|Item|SourceVersion'` and
|
||||
`go test . -run 'Placement|Destination|SourceInfo|SourceVersion|PublicFacadeBoundary|DependencyImportBoundary'`.
|
||||
|
||||
## Stage 5 — Integrate, Document, And Verify The Result
|
||||
|
||||
Findings: final acceptance for **C03, F07**. Status: **complete**. Validation
|
||||
passed on Linux/amd64 (Go 1.26.10):
|
||||
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh`,
|
||||
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...`, and
|
||||
`git diff --check`. Existing integration coverage was reviewed; no additional
|
||||
case was needed.
|
||||
|
||||
1. Review both paths together: defaults resolving wildcard discovery in ordinary,
|
||||
layered, and transitive imports; placement carrying the resulting sources
|
||||
through composition and inspection; and failures stopping before later hooks.
|
||||
Existing integration tests own these interactions. Add only a concrete missing
|
||||
case rather than a second copy of the complete matrix or a new fixture framework.
|
||||
2. Inspect the resulting handoffs. Confirm the glob selector no longer embeds
|
||||
per-entry preparation, the engine has one defaults input type, direct fields
|
||||
replaced the four input decorators, and the load snapshot is not repeated at
|
||||
each private representation. Keep domain-specific policy/registration and
|
||||
root conversion boundaries, necessary ownership copies, and existing budgets.
|
||||
3. Update `docs/internal/imports.md` for the selector/preparation split and
|
||||
`docs/internal/overview.md` for defaults conversion, preflight snapshot ownership,
|
||||
and direct placement construction. Keep algorithms private and avoid public
|
||||
performance or copy-count guarantees. No ADR/policy change is needed.
|
||||
4. Replace C03/F07 detail in the first two `docs/roadmap/future.md` entries with
|
||||
short pointers to `internal-boundaries.md`. Retain their heading anchors for
|
||||
existing audit links; leave other future candidates unchanged. Correct old
|
||||
roadmap references that would otherwise mistake this replacement plan for
|
||||
the previous sprint's completed record: use
|
||||
`bb5540b:docs/roadmap/implementation.md` for that historical plan. Do not reopen
|
||||
the prior audit or retire unrelated planning records in this sprint.
|
||||
5. Format changed Go files, finish deferred checks, run the final checks below,
|
||||
and inspect the complete diff for API, dependency, semantics, or scope changes.
|
||||
Record actual results and platform limitations here. Commit focused nonempty
|
||||
units and hand off a clean workspace; do not create empty validation commits.
|
||||
|
||||
Final validation, following the existing development guide:
|
||||
|
||||
```sh
|
||||
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off ./scripts/check.sh
|
||||
env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test -race ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The everyday check includes consumers, documentation links, and public/dependency
|
||||
boundary tests. Confirm those existing tests ran rather than adding redundant
|
||||
final invocations. Report filesystem/platform skips accurately; express macOS
|
||||
validation, CI completion, release publication, extended fuzz campaigns, and
|
||||
benchmarks are not additional acceptance requirements.
|
||||
|
||||
No product or architectural question remains open. The concrete choices above
|
||||
complete the plan without changing the roadmap's public contracts; private
|
||||
formatting and file organization can follow local idiom.
|
||||
346
docs/roadmap/interface-remediation.md
Normal file
346
docs/roadmap/interface-remediation.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# Interface Contract Remediation Roadmap
|
||||
|
||||
Status: target settled, including shared history ownership; implementation has
|
||||
not begun.
|
||||
Planning baseline: `1f64673` (v0.10.2 implementation plus documentation changes).
|
||||
|
||||
This roadmap covers the four findings in the [interface audit](interfaces.md).
|
||||
The audit owns reproductions, evidence, and finding numbers. This document owns
|
||||
the combined target, required changes, and general dependency order. A subsequent
|
||||
implementation plan should own concrete stages, private implementation choices,
|
||||
validation commands, and progress.
|
||||
|
||||
## Purpose And Scope
|
||||
|
||||
Restore agreement between ConfigKit's presence, metadata, history, and diagnostic
|
||||
interfaces under their existing contracts. The first three findings concern
|
||||
semantic paths that share a JSON Pointer spelling; the fourth concerns a
|
||||
composition claimant order that is lost during diagnostic canonicalization.
|
||||
|
||||
Build one shared internal history model from which metadata history and
|
||||
explanation are derived. It will own the historical evidence and semantic
|
||||
attribution used by both views, replacing their independent reconstruction of
|
||||
that attribution. This is the selected resolution of OD-1.
|
||||
|
||||
The [Path declarations](../../path.go), [metadata contract](../config.md#layered-metadata),
|
||||
[resolution contract](../config.md#recorded-application-resolution), and
|
||||
[strict-composition contract](../config.md#strict-document-composition) remain
|
||||
authoritative. This work changes the implementation of those contracts without
|
||||
selecting a new public API, path syntax, precedence rule, or configuration
|
||||
restriction. The existing root-owned facade and engine responsibilities remain.
|
||||
|
||||
## Current Baseline And Targeted End State
|
||||
|
||||
An explicit mapping-key path and an explicit sequence-index path can both render
|
||||
as `/shape/0`. `ParsePath` deliberately leaves numeric tokens contextual, whereas
|
||||
`Keys` and `Index` preserve the caller's explicit choice. The engine already has
|
||||
shape-aware effective/historical path indexes and a frozen resolution-entry view,
|
||||
but some queries and metadata updates still use pointer spelling alone.
|
||||
|
||||
History currently reconstructs definition and previous-origin records, while
|
||||
explanation replays source contributions, inheritance, decoding checkpoints, CLI
|
||||
assignments, and grouped resolution snapshots. Neither public representation
|
||||
contains everything needed by the other. The target is shared underlying evidence
|
||||
with distinct projections for each interface, preserving their existing behavior.
|
||||
|
||||
| Finding | Current discrepancy | Target |
|
||||
| --- | --- | --- |
|
||||
| 1: resolution presence | `IsExplicit` and `Default` consult string-keyed presence after schema validation, which cannot determine actual shape beneath dynamic values. | Presence decisions use the matching semantic path in the fixed resolution-entry state. |
|
||||
| 2: effective explicitness | Resolution retains old field information when pointer spelling survives a container-shape change. | Input explicitness carries forward for the same semantic path; a newly introduced key or index does not inherit another path's explicitness. |
|
||||
| 3: history attribution | A historical-shape check admits the query, then `History` returns the entire textual-pointer bucket; explanation selects evidence separately. | Both interfaces derive attribution from one shared history model, selecting exact definitions, previous origins, and applicable ancestor operations by semantic path. |
|
||||
| 4: claimant order | Composition constructs related locations in assembly order, then canonicalization sorts them by location. | Structured and rendered composition conflicts preserve the selected claimant order. |
|
||||
|
||||
For example, if input contains `shape: {'0': old}` and recorded application
|
||||
resolution changes `shape` to a sequence, the old key remains an input path in
|
||||
the callback's entry view. The new index is effective in final metadata but was
|
||||
not explicitly supplied by that input. Its history must not present the old key's
|
||||
definition as its own. This distinction concerns container shape, not identity
|
||||
tracking for records that move within a sequence.
|
||||
|
||||
## Required Changes
|
||||
|
||||
### 1. Use Entry-State Shape For Resolution Presence
|
||||
|
||||
In [resolution.go](../../internal/engine/resolution.go), align `IsExplicit` and
|
||||
the explicitness decision in `Default` with the frozen, shape-aware entry metadata
|
||||
already used by `InputInfo` in
|
||||
[load_metadata.go](../../internal/engine/load_metadata.go).
|
||||
|
||||
Reuse the existing semantic-path matching machinery. A shared private lookup can
|
||||
avoid divergent presence decisions without requiring every public query to have
|
||||
identical error behavior. In particular:
|
||||
|
||||
- Preserve `IsExplicit`'s schema-path validation and its existing diagnostics.
|
||||
`InputInfo` remains a query that returns false for missing or mismatched paths
|
||||
without those diagnostics.
|
||||
- Preserve `Default`'s helper-target validation, assignment, recording, and final
|
||||
helper-value checks. Correct its presence gate; do not bypass the other checks.
|
||||
- Use entry-state shape even after application code changes the working value.
|
||||
Explicit false, zero, empty, and null retain their existing presence meaning.
|
||||
- Keep callback lifetime and detached metadata behavior intact.
|
||||
|
||||
A shape mismatch beneath a schema-valid dynamic path must not become explicit
|
||||
merely because its pointer spelling matches an input path. Contextually parsed
|
||||
numeric tokens continue to resolve against the entry state's actual containers.
|
||||
|
||||
### 2. Preserve Explicitness By Semantic Path During Resolution
|
||||
|
||||
Update resolution metadata application and field refresh in
|
||||
[load_metadata.go](../../internal/engine/load_metadata.go) to compare the relevant
|
||||
entry and final shapes before reusing presence information.
|
||||
|
||||
The comparison must account for container kinds along the full path. A mapping
|
||||
key becoming a sequence index at an ancestor can change a descendant's semantic
|
||||
path even when its last segment and full pointer spelling stay unchanged.
|
||||
|
||||
Keep explicitness for matching surviving paths, including descendants covered by
|
||||
an ancestor normalization record. New semantic paths introduced by resolution
|
||||
remain non-explicit, and retain the applicable recorded resolution origin.
|
||||
Removed paths leave effective metadata while remaining available to historical
|
||||
queries under the existing contract. Do not clear every descendant's presence
|
||||
just because its parent was normalized.
|
||||
|
||||
Use the existing before/after projection and entry metadata where possible.
|
||||
This work does not require tracking every transient mutation inside a callback;
|
||||
the existing net-change and coalesced-record model continues to apply.
|
||||
|
||||
### 3. Build A Shared Internal History Model
|
||||
|
||||
#### Evidence And Ownership
|
||||
|
||||
Give historical evidence one private owner in the engine. Composition,
|
||||
inheritance, decoding, CLI assignment, and resolution continue to perform their
|
||||
existing work and supply evidence to that owner. The model does not drive a
|
||||
second configuration load or repeat application callbacks.
|
||||
|
||||
Organize the evidence into ordered groups with the phase boundaries required by
|
||||
the existing inspection contracts:
|
||||
|
||||
| Evidence group | Information to preserve |
|
||||
| --- | --- |
|
||||
| Default baseline | Canonical typed application defaults or Go-zero values and their default attribution. |
|
||||
| Source contributions | Underlay, main-tier, and selected-overlay contributions in their existing order; exact definitions, composite origins, displaced definitions, raw policy-validated values, and sequence-position projections. |
|
||||
| Inheritance | Parent transfers, child contributions including equal-valued replacements, controlling directives and their consumption, identity-field preservation, and contribution origins/dependencies. |
|
||||
| Typed checkpoints | Pre-CLI and post-CLI typed projections and attribution needed to reconcile retained defaults and raw contributions with decoded values. |
|
||||
| CLI contributions | Explicit assignments, including equal-valued assignments, raw supplied representation, source attribution, and affected paths. |
|
||||
| Resolution | Original recorded operations, reasons, dependencies, and before/after evidence, with the grouping needed to explain overlapping records as net subtree changes. |
|
||||
|
||||
The logical order remains baseline, source contributions, inheritance, pre-CLI
|
||||
decoding reconciliation, CLI contributions, post-CLI typed reconciliation, then
|
||||
recorded resolution. The two typed checkpoints belong on their respective sides
|
||||
of the CLI contribution phase.
|
||||
|
||||
The groups describe logical inspection chronology, not a transcript of every
|
||||
physical mutation. Retain the existing net-change and coalescing rules for
|
||||
resolution. Preserve superseded raw input that need not decode into the final
|
||||
application type; typed checkpoints do not replace that evidence.
|
||||
|
||||
Use a small set of ordinary private structures and existing document/path
|
||||
mechanisms. Exact types, indexes, and storage layout belong in the implementation
|
||||
plan. Complete evidence means enough for the existing history, explanation, and
|
||||
internal attribution consumers; it does not add persistence, a public event API,
|
||||
or recording of transient application effects.
|
||||
|
||||
#### Shared Selection And Attribution
|
||||
|
||||
Retain concrete semantic-path evidence when it is available. A query over the
|
||||
model must distinguish exact definitions from operations affecting an ancestor
|
||||
or descendant, determine the relevant previous origins, and apply sequence
|
||||
contribution coordinates at their assigned positions.
|
||||
|
||||
The affected semantic path and the operation's reported path are distinct facts.
|
||||
For example, a replacement at `/shape` can remove a mapping key and introduce a
|
||||
sequence index with the same pointer spelling. Preserve enough before/after shape
|
||||
evidence to answer each explicit query correctly. Merely filtering `Change.Path`
|
||||
after concrete segment kinds have been discarded is insufficient.
|
||||
|
||||
Make this selection and attribution common to both projections, including
|
||||
inheritance integration and `Previous` reconstruction. A false definition must
|
||||
not survive as a previous origin after it has been excluded from the query's
|
||||
history. Preserve genuine ancestor replacement/removal effects and chronology.
|
||||
A path that never existed does not acquire history solely because an ancestor
|
||||
changed.
|
||||
|
||||
Keep the contextual meaning of parsed numeric tokens across historical states
|
||||
and the one-entry-per-canonical-pointer enumeration of `HistoricalPaths`.
|
||||
Indexes and compact derived records may remain, but their attribution must come
|
||||
from the shared evidence. Metadata queries should not require full configuration
|
||||
replay or YAML rendering to obtain definition and origin records.
|
||||
|
||||
#### Distinct Public Projections
|
||||
|
||||
`Metadata.History` produces detached definition, inheritance, and resolution
|
||||
records from the model. `Explain` reconstructs and safely renders values from
|
||||
the same evidence, retaining its existing decoding and grouping behavior. Neither
|
||||
public API should be implemented by translating the other's public result.
|
||||
|
||||
Preserve these intentional differences:
|
||||
|
||||
| Case | Metadata history | Explanation |
|
||||
| --- | --- | --- |
|
||||
| Several strict contributions to a mapping | Composite definition/origin information. | Each contribution's effect on the mapping. |
|
||||
| Raw input differs from canonical typed values | Definition and attribution records. | The applicable decoding reconciliation transition. |
|
||||
| Overlapping resolution records | Specific recorded attribution under the existing coverage rules. | One net subtree event with combined reasons and dependencies. |
|
||||
| Equal-valued input or inheritance operations | Explicit definition and operation attribution remains visible. | Relevant operations remain visible even when before and after values are equal. |
|
||||
|
||||
Agreement means consistent semantic identity and attribution, not identical
|
||||
record lists. Retain original resolution records as evidence from which net
|
||||
replay groups are derived; a grouped explanation alone loses information needed
|
||||
by metadata.
|
||||
|
||||
#### Replace Existing Attribution Machinery
|
||||
|
||||
The common model must replace independent historical attribution, rather than
|
||||
become another representation alongside it. The implementation plan should map
|
||||
the following owners to their replacements:
|
||||
|
||||
| Current owner | Required change |
|
||||
| --- | --- |
|
||||
| History-building portions of `metadataFromDecoded`, `applyOverlayMetadata`, and `appendPostDecodeCLIHistory` in [load_metadata.go](../../internal/engine/load_metadata.go) and [override_context.go](../../internal/engine/override_context.go) | Produce or project shared evidence instead of independently reconstructing pointer-keyed histories. Retain effective-field construction where it is still needed. |
|
||||
| Previous-origin reconstruction and inheritance insertion in [inheritance_history.go](../../internal/engine/inheritance_history.go) | Use common semantic selection and attribution while preserving the existing inheritance projection rules. |
|
||||
| Relevance and chronology orchestration in [explain.go](../../internal/engine/explain.go) | Consume the shared model; retain useful value reconstruction and redaction helpers. |
|
||||
| Separate published inheritance evidence for metadata and explanation in [load_result.go](../../internal/engine/load_result.go) | Consolidate under the frozen history owner where independent copies are unnecessary; keep required output detachment. |
|
||||
| Original and coalesced resolution evidence in [history.go](../../internal/engine/history.go) | Own original records in the model and derive the replay grouping without losing specific attribution or maintaining a second independently interpreted history. |
|
||||
|
||||
Existing source, sequence-combination, and inheritance evidence producers may
|
||||
remain useful. Their algorithms need not be rewritten merely to contribute to
|
||||
the model. Likewise, final effective fields, the frozen resolution-entry view,
|
||||
source records, and final configuration snapshots retain their responsibilities.
|
||||
Findings 1 and 2 still require explicit presence corrections.
|
||||
|
||||
#### Other Consumers, Lifetime, And Resource Accounting
|
||||
|
||||
Move historical dependency reads in
|
||||
[secret classification](../../internal/engine/secret.go) and historical location
|
||||
fallbacks in [override_context.go](../../internal/engine/override_context.go) to
|
||||
the shared evidence. Do not retain the old history machinery solely for those
|
||||
readers. Preserve existing sensitivity propagation and historical classification;
|
||||
correcting attribution does not authorize narrowing redaction for paths that
|
||||
share a pointer spelling.
|
||||
|
||||
Evidence must be available at the lifecycle stages where these internal readers
|
||||
need it, including diagnostic attribution before publication. Keep the callback's
|
||||
entry metadata fixed while later resolution evidence is added. Finalize the
|
||||
model for published queries without introducing lazy mutation of shared state.
|
||||
Private frozen evidence may be shared by the two views while their returned
|
||||
records remain detached under the existing ownership contract.
|
||||
|
||||
Reuse retained trees and existing lookup machinery where practical. Review
|
||||
retained-history accounting against the resulting ownership graph, including
|
||||
typed checkpoints and inheritance/resolution evidence. The model does not require
|
||||
a full document copy per event, a new quota, or a new concurrency guarantee.
|
||||
Reduced duplication is a design benefit to evaluate, not a promised memory or
|
||||
speed improvement.
|
||||
|
||||
### 4. Preserve Composition Claimant Order Through Diagnostics
|
||||
|
||||
Keep the order produced by `compositionConflictDiagnostic` in
|
||||
[compose.go](../../internal/engine/compose.go) through accumulation, `LoadError`,
|
||||
facade conversion, and `WriteDiagnostics` in
|
||||
[diagnostic.go](../../internal/engine/diagnostic.go).
|
||||
|
||||
Prefer a narrow internal canonicalization rule based on the diagnostic semantics
|
||||
already carried across these boundaries. Identify the composition diagnostic
|
||||
producers governed by the claimant-order contract rather than changing all
|
||||
related-location lists. Preserve canonical ordering of diagnostic records and
|
||||
the existing sorting of related locations for other diagnostics.
|
||||
|
||||
The root renderer converts public diagnostic records back to engine records and
|
||||
canonicalizes them again. An engine-only marker discarded by facade conversion
|
||||
would therefore be insufficient. No public ordering option is needed for this
|
||||
correction. Keep primary selection, conflict detection, acquisition order, and
|
||||
dependency-ready scheduling unchanged.
|
||||
|
||||
## General Implementation Order
|
||||
|
||||
1. Prepare the concrete shared-evidence design and focused regression cases.
|
||||
Map existing producers and consumers to the new owner, distinguish common
|
||||
attribution from presentation rules, and identify the old paths to remove.
|
||||
Select representations and indexes that preserve existing query and lifetime
|
||||
needs without adding public contracts.
|
||||
2. Repair entry-state presence queries and `Default` (finding 1). This is a local
|
||||
correction that can proceed independently of the larger history work.
|
||||
3. Repair final explicitness preservation (finding 2), retaining the shape evidence
|
||||
needed by metadata updates. Coordinate this work with history construction so
|
||||
the two fixes do not introduce competing definitions of path identity.
|
||||
4. Build the shared model and migrate metadata history, explanation, sensitivity
|
||||
dependencies, and diagnostic-location readers. Preserve phase evidence and
|
||||
derive both public projections through common semantic attribution. Remove
|
||||
the superseded reconstruction paths and reconcile retained-history accounting.
|
||||
Verify finding 3 and its interaction with final presence metadata; repairing
|
||||
pointer buckets alone does not complete the selected architecture.
|
||||
5. Complete composition diagnostic ordering (finding 4) as an independent change;
|
||||
it may proceed alongside the path work. Check rendering as well as the
|
||||
structured error before considering it complete.
|
||||
6. Review the integrated result, run applicable development checks, and update
|
||||
affected implementation documentation and finding status.
|
||||
|
||||
These are high-level dependencies, not a requirement to combine unrelated fixes
|
||||
in one commit. The implementation plan should select concrete edits and test
|
||||
commands while keeping the four findings individually traceable.
|
||||
|
||||
## Validation And Completion
|
||||
|
||||
Follow the [testing policy](../policy/testing.md) and use the
|
||||
[development guide](../development.md#baseline-validation) for checks. Extend
|
||||
existing test owners rather than recreating full internal matrices at the facade.
|
||||
The focused cases should cover:
|
||||
|
||||
- Both mapping-key/index mismatch directions beneath dynamic values, including
|
||||
explicit paths and contextual parsed pointers. Check callback presence and
|
||||
`Default` behavior, including the fixed entry view after working-value changes.
|
||||
- Both container-shape transitions during recorded resolution, a descendant
|
||||
affected by an ancestor shape change, and a same-shape normalization control.
|
||||
Check explicitness and recorded origins through final metadata and explanation.
|
||||
- Shape-changing CLI and resolution history, checking exact definitions,
|
||||
`Previous`, and genuine ancestor replacement/removal. Include contextual
|
||||
enumeration/query coverage and preserve existing inheritance and overlay cases;
|
||||
add another case only where those integrations expose an unprotected risk.
|
||||
- Shared-model projections across composite mapping origins, typed decoding and
|
||||
default reconciliation, equal-valued writes, inheritance controls/transfers,
|
||||
mapped sequence replacements and removed descendants, and overlapping
|
||||
resolution records. Reuse existing cases where they protect those semantics;
|
||||
check the intentional differences between the public projections as well as
|
||||
agreement on attribution.
|
||||
- Historical sensitivity/dependency propagation, diagnostic source-location
|
||||
fallback, detached returned records, and complete retained-history accounting
|
||||
under the new ownership graph. Use small controlled budget cases and existing
|
||||
disclosure fixtures rather than memory or timing thresholds.
|
||||
- A composition conflict whose acquisition and assembly orders differ, using the
|
||||
audit fixture. Check both published diagnostics and rendered related-location
|
||||
order, while retaining coverage for ordinary diagnostic canonicalization.
|
||||
|
||||
Retain existing checks for invalid schema paths, callback lifetime, detached
|
||||
records, safe historical rendering, and default/null presence semantics. Test
|
||||
observable behavior rather than a particular private index or number of copies.
|
||||
No benchmark campaign, broader audit, or new release gate is part of this roadmap.
|
||||
|
||||
Completion requires all four findings to have implementation and regression
|
||||
evidence, appropriate development checks, and both history interfaces deriving
|
||||
semantic attribution from the shared model. Review the final producer/consumer
|
||||
map to confirm superseded independent reconstruction has been removed and
|
||||
internal attribution readers have migrated. Passing the four defect regressions
|
||||
while retaining the old parallel attribution machinery is not completion.
|
||||
|
||||
The audit's previous passing suite is not validation of these future changes.
|
||||
Scope here does not promise identical output from different inspection interfaces
|
||||
or a universal guarantee about unrelated path-processing code.
|
||||
|
||||
## Decision Status
|
||||
|
||||
OD-1 is settled: build one shared internal history model with distinct metadata
|
||||
and explanation projections. The design investigation found that both views
|
||||
require evidence the other's public representation does not preserve. Shared
|
||||
ownership can replace independent attribution and redundant retained evidence,
|
||||
while keeping the current lifecycle and public contracts.
|
||||
|
||||
The narrower repair of stored history events would leave the separate replay
|
||||
attribution owner in place. Promoting current explanation output to authoritative
|
||||
history would lose composite-definition and specific resolution information.
|
||||
The selected common underlying model addresses both concerns. This accepts a
|
||||
larger internal refactor than a local shape-filtering fix.
|
||||
|
||||
No significant open decision remains at roadmap level. Private representation,
|
||||
indexing, and helper choices belong in the implementation plan. This roadmap
|
||||
records future implementation ownership; it does not revise accepted ADRs or
|
||||
declare the model implemented.
|
||||
202
docs/roadmap/interfaces.md
Normal file
202
docs/roadmap/interfaces.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# API and Interface Contract Audit
|
||||
|
||||
Reviewed baseline: `01b08e76a3fecc22c4414437bfeaa07b4b7a85aa`.
|
||||
|
||||
Status: four open findings. This is an audit record, not an implementation plan.
|
||||
No production code or repository tests were changed.
|
||||
|
||||
The [interface remediation roadmap](interface-remediation.md) describes the
|
||||
settled target, required changes, and dependencies, including a shared internal
|
||||
history model for metadata history and explanation. Implementation remains pending.
|
||||
This audit retains the findings and reproduction evidence.
|
||||
|
||||
## Scope and evidence
|
||||
|
||||
Reviewed the root-owned public facade and its conversions, engine loading and
|
||||
callback boundaries, source/discovery and filesystem ownership, schema and typed
|
||||
value handling, metadata/history, safe rendering and comparison, secrets, and
|
||||
the document/YAML boundary against declaration comments, the
|
||||
[configuration contract](../config.md), and applicable architectural decisions.
|
||||
The root delegates to the synchronous engine lifecycle; parser-independent
|
||||
documents support source assembly and inheritance, and completed results retain
|
||||
detached configuration and frozen inspection state.
|
||||
|
||||
The findings below were reproduced through the public API with a disposable Go
|
||||
program outside the repository, using labeled byte sources and `fstest.MapFS`.
|
||||
The full existing suite passed with Go 1.26.10 on Linux:
|
||||
|
||||
```sh
|
||||
GOTOOLCHAIN=local GOTELEMETRY=off GOPROXY=off go test ./... -count=1
|
||||
```
|
||||
|
||||
The existing tests cover many ownership, lifetime, error, and zero-value cases,
|
||||
but do not detect these cross-interface discrepancies. No benchmarks, external
|
||||
service calls, or new test files were needed. The recommendations restore
|
||||
existing contracts rather than introduce new API restrictions or guarantees.
|
||||
|
||||
## 1. Resolution presence queries confuse explicit keys with sequence indexes
|
||||
|
||||
Priority: P2. Status: open.
|
||||
|
||||
Relevant code:
|
||||
|
||||
- [`Resolution.IsExplicit`](../../internal/engine/resolution.go), lines 75–82.
|
||||
- `Resolution.InputInfo` and `resolutionInput.lookup` in
|
||||
[resolution.go](../../internal/engine/resolution.go) and
|
||||
[load_metadata.go](../../internal/engine/load_metadata.go).
|
||||
- `resolveSchemaPath` in [schema.go](../../internal/engine/schema.go).
|
||||
|
||||
The public [path contract](../../path.go) distinguishes explicit mapping keys
|
||||
from sequence indexes. The [resolution contract](../config.md#recorded-application-resolution)
|
||||
defines surviving-input presence and a fixed entry-state metadata view;
|
||||
[metadata queries](../config.md#layered-metadata) preserve container shapes.
|
||||
|
||||
`IsExplicit` validates against the schema but then looks up presence using only
|
||||
`path.String()`. In dynamic `any` state, schema validation cannot establish the
|
||||
actual container shape. Consequently these distinct queries collide:
|
||||
|
||||
```go
|
||||
key := configkit.Keys("shape", "0")
|
||||
index := configkit.Keys("shape").Index(0)
|
||||
```
|
||||
|
||||
Load `map[string]any` from `shape: [input]` with a resolver that queries both
|
||||
paths, without changing the configuration. Observed results:
|
||||
|
||||
| Query | `Resolution.IsExplicit` | `Resolution.InputInfo` found | Final `Metadata.IsExplicit` |
|
||||
| --- | --- | --- | --- |
|
||||
| Explicit mapping key | true | false | false |
|
||||
| Explicit sequence index | true | true | true |
|
||||
|
||||
The mapping-key path never existed and should not be reported as explicit.
|
||||
Applications branching on presence receive inconsistent answers depending on
|
||||
which public query they use, even during an otherwise unchanged load.
|
||||
|
||||
Potential remediation: use the retained entry-state semantic shape when checking
|
||||
resolution presence, consistently with `InputInfo` and `Metadata.IsExplicit`.
|
||||
Review the same string-only presence lookup in `Default` while preserving its
|
||||
existing assignment and callback-lifetime rules. Add focused public coverage for
|
||||
both mapping-to-index and index-to-mapping mismatches under dynamic values,
|
||||
including contextually parsed pointers. This does not require changing the
|
||||
documented treatment of invalid schema paths.
|
||||
|
||||
## 2. Resolution can transfer explicitness to a different semantic path
|
||||
|
||||
Priority: P2. Status: open.
|
||||
|
||||
Relevant code: `applyResolutionMetadata`, `applyResolutionFieldOrigins`, and
|
||||
`refreshResolutionFields` in
|
||||
[load_metadata.go](../../internal/engine/load_metadata.go), especially lines
|
||||
371–387; final shape checking in
|
||||
[`Metadata.effectiveField`](../../internal/engine/metadata.go).
|
||||
|
||||
The [metadata contract](../config.md#layered-metadata) distinguishes key/index
|
||||
shapes and keeps derived values for absent input non-explicit. Recorded
|
||||
resolution changes origins without creating input explicitness.
|
||||
|
||||
Reproduction:
|
||||
|
||||
1. Load `map[string]any` from `shape: {'0': old}`.
|
||||
2. In resolution, replace `configuration["shape"]` with `[]any{"new"}` and
|
||||
record normalization at `Keys("shape")`.
|
||||
3. Query final metadata using the two paths from finding 1.
|
||||
|
||||
The old explicit mapping-key path correctly stops being effective, but the newly
|
||||
created sequence index reports `Explicit: true`. That sequence index was never
|
||||
supplied by input. `refreshResolutionFields` reuses the old `FieldInfo` whenever
|
||||
its textual pointer survives; it updates null state without checking whether
|
||||
the old and new containers give that pointer different semantic shapes. Final
|
||||
lookup validates only the new shape, so it cannot recover the lost distinction.
|
||||
|
||||
This can mislead consumers inspecting whether a final value was explicitly
|
||||
configured rather than introduced by application resolution. The converse
|
||||
sequence-to-numeric-key transition has the same underlying collision.
|
||||
|
||||
Potential remediation: retain entry/effective shape evidence across resolution
|
||||
and carry input explicitness forward only for the matching semantic path.
|
||||
Preserve existing explicitness for same-shape paths and the recorded resolution
|
||||
origin; do not clear every descendant's presence merely because an ancestor was
|
||||
normalized. Cover both shape transitions and agreement among `Lookup`,
|
||||
`IsExplicit`, and explanation. Fixing finding 1's callback query alone does not
|
||||
repair this publication path.
|
||||
|
||||
## 3. Metadata history mixes numeric-key and sequence-index definitions
|
||||
|
||||
Priority: P2. Status: open.
|
||||
|
||||
Relevant code:
|
||||
|
||||
- [`Metadata.History`](../../internal/engine/metadata.go), lines 56–65.
|
||||
- `metadataFromDecoded`, `applyOverlayMetadata`, and history event construction
|
||||
in [load_metadata.go](../../internal/engine/load_metadata.go).
|
||||
- Shape-aware replay in [`Inspection.Explain`](../../internal/engine/explain.go).
|
||||
|
||||
The [metadata contract](../config.md#layered-metadata) promises semantic
|
||||
key/index distinctions for historical as well as effective queries. The
|
||||
historical-path index retains both shapes, but `History` uses that index only to
|
||||
check that the query was ever known. It then retrieves the entire history bucket
|
||||
by textual pointer, without selecting the query's shape.
|
||||
|
||||
Reproduction: load `shape: {'0': old}` into `map[string]any`, then apply a CLI
|
||||
override at `Keys("shape")` with `[]any{"new"}`. Both explicit shapes were
|
||||
observed and share `/shape/0`.
|
||||
|
||||
`History(Keys("shape").Index(0))` incorrectly includes the original base
|
||||
definition of the mapping key, and identifies that base origin as the previous
|
||||
origin of the CLI operation. The old input defined a key, not an index. In the
|
||||
same successful result, `Explain` for the explicit index correctly shows only
|
||||
its CLI introduction. `History` for the key and index returns identical records
|
||||
despite their different lifetimes.
|
||||
|
||||
Consequences include false source attribution and disagreement between the two
|
||||
public history interfaces when consumers inspect removed or introduced paths.
|
||||
|
||||
Potential remediation: retain enough container-shape evidence with stored
|
||||
history events, or select relevant events from existing shape-aware replay
|
||||
evidence. Filter exact definitions and previous origins by the queried semantic
|
||||
shape while retaining genuine ancestor replacement/removal events. Filtering
|
||||
only on the current `Change.Path` is insufficient where construction already
|
||||
replaced concrete segment kinds with parsed tokens. Preserve the intentional
|
||||
one-pointer-per-entry `HistoricalPaths` enumeration and contextual behavior of
|
||||
`ParsePath`; neither public contract needs redesign. Cover both direction changes
|
||||
and compare metadata history with explanation.
|
||||
|
||||
## 4. Diagnostic canonicalization breaks composition claimant ordering
|
||||
|
||||
Priority: P3. Status: open.
|
||||
|
||||
Relevant code: `compositionConflictDiagnostic` in
|
||||
[compose.go](../../internal/engine/compose.go) and `canonicalDiagnostics` in
|
||||
[diagnostic.go](../../internal/engine/diagnostic.go), particularly lines 88–92.
|
||||
The latter runs both while constructing `LoadError` and in `WriteDiagnostics`.
|
||||
|
||||
The [strict-composition contract](../config.md#strict-document-composition)
|
||||
specifies the first claim in scoped assembly order as primary and every remaining
|
||||
claim in the same order as related locations.
|
||||
|
||||
A native-filesystem fixture demonstrates the discrepancy:
|
||||
|
||||
```text
|
||||
main.yml imports a.yml, b.yml, c.yml
|
||||
a.yml imports z.yml
|
||||
b.yml, c.yml, and z.yml each define x
|
||||
```
|
||||
|
||||
Acquisition assigns main=source-1, a=source-2, z=source-3, b=source-4,
|
||||
c=source-5. Dependency-ready assembly considers the three conflicting claims
|
||||
in b, c, z order. The composer correctly chooses b as primary and constructs
|
||||
related locations c, z. Generic canonicalization sorts those related locations
|
||||
by acquisition ID, publishing z, c instead. The reproduced public diagnostic
|
||||
has primary source-4 and related source-3, source-5.
|
||||
|
||||
All claimants remain present and the conflict still fails loading, so this is
|
||||
lower impact than the presence/history defects. It nevertheless violates the
|
||||
documented reporting order and gives consumers two different ordering rules
|
||||
within one conflict report.
|
||||
|
||||
Potential remediation: preserve the selected semantic ordering for composition
|
||||
claimant lists through accumulation and rendering, while retaining canonical
|
||||
ordering of diagnostic records and the established sorting of related locations
|
||||
for diagnostics without a semantic-order contract. Add one public failure-path
|
||||
case whose acquisition and claimant orders differ and verify both structured
|
||||
diagnostics and rendered output. No scheduler or precedence change is needed.
|
||||
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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md).
|
||||
|
||||
The goal is clearer responsibility and ownership with fewer redundant internal
|
||||
representations. Both changes preserve the public API and configuration model.
|
||||
They are independent work areas within one maintenance effort, not a new loading
|
||||
workflow. The [implementation plan](implementation.md) owns concrete edits,
|
||||
stages, validation commands, and execution progress.
|
||||
|
||||
## Baseline And Targeted End State
|
||||
|
||||
The preceding sprint repaired directory-failure classification and shared link
|
||||
dependency evaluation, clarified acquisition handoffs, and removed unused
|
||||
placement bridges. Those changes provide the baseline; this work does not repeat
|
||||
or redesign them.
|
||||
|
||||
| Area | Current baseline | Target |
|
||||
| --- | --- | --- |
|
||||
| Glob discovery | `selectGlobImportCandidates` interleaves traversal and set operations with substantial per-entry preparation. | The selector coordinates discovery; a cohesive private operation prepares a wildcard-discovered entry. |
|
||||
| Facade transport | Import defaults pass through root conversion, an exported engine transport record, an equivalent private engine record, and repeated snapshots. Placement construction also uses setter-style bridges. | Root-owned public records convert explicitly to one engine defaults representation, with straightforward engine option/input construction and identifiable ownership copies. |
|
||||
|
||||
The [architecture policy](../policy/architecture.md), [configuration
|
||||
contract](../config.md), and public declarations remain authoritative. [ADR
|
||||
0006](../adr/0006-public-facade.md) continues to govern the facade boundary. These
|
||||
refactors do not require a new architectural decision or changes to its accepted
|
||||
decision. The [component overview](../internal/overview.md) and [import
|
||||
internals](../internal/imports.md) own implemented structure.
|
||||
|
||||
## Glob Entry Preparation
|
||||
|
||||
### Target Responsibility Split
|
||||
|
||||
In [glob.go](../../internal/engine/glob.go), the selector continues to own pattern
|
||||
validation, component advancement, literal-segment handling, directory
|
||||
enumeration and its accounting, candidate union and sorting, final target
|
||||
validation, exclusions, and requiredness.
|
||||
|
||||
A private glob-specific preparation operation owns the patterned branch's work
|
||||
for one directory entry: matching and hidden-entry filtering, lexical/access path
|
||||
construction, containment checks, target inspection and discovered-link policy,
|
||||
intermediate-directory eligibility, host/native access-path selection, labels,
|
||||
and candidate construction. Its result distinguishes a selected candidate, an
|
||||
ordinary skip, and a diagnostic. It receives the facts needed for that entry
|
||||
rather than mutating the selector's candidate set or surrounding traversal state.
|
||||
|
||||
Use the existing filesystem access and link-resolution helpers. Directory
|
||||
selection already has `prepareImportDirectoryEntry`; it remains a separate owner
|
||||
because its recursion, matching, absence handling, and traversal state differ.
|
||||
Share a smaller operation only where it actually expresses the same semantics
|
||||
and reduces duplication. No common visitor or discovery strategy is needed for
|
||||
this extraction.
|
||||
|
||||
### Behavior To Preserve
|
||||
|
||||
Preserve the current distinctions and processing order documented by
|
||||
[explicit imports](../config.md#explicit-imports) and exercised by the selection
|
||||
tests:
|
||||
|
||||
- Literal components remain explicit selections; discovery filters apply at
|
||||
their existing wildcard boundaries. Moving matching or hidden filtering after
|
||||
filesystem inspection would change which errors are observable.
|
||||
- Keep lexical candidate identity separate from resolved access identity.
|
||||
Native source identity and relative imports retain namespace spelling; host
|
||||
access retains its existing canonicalization and containment behavior.
|
||||
- Keep discovered-link follow rules, intermediate-directory eligibility,
|
||||
missing-target handling, and structured diagnostic sites/classifications.
|
||||
Do not make glob and directory rules identical as a side effect of extraction.
|
||||
- Preserve deterministic candidate and error order, union/deduplication,
|
||||
directory-entry accounting, and the existing point of source admission.
|
||||
Final selected-file validation remains before exclusions where it is today;
|
||||
exclusions do not hide discovery or resource errors.
|
||||
- Retain the preceding sprint's distinction between missing-on-open directories
|
||||
and failures after opening, along with operation-local completed-link reuse.
|
||||
|
||||
The intended benefit is that a maintainer can understand traversal separately
|
||||
from entry eligibility and identity preparation. This is a readability change;
|
||||
it does not select a different search algorithm or claim a speedup.
|
||||
|
||||
## Facade-To-Engine Transport
|
||||
|
||||
### Target Representations
|
||||
|
||||
The root keeps its public `ImportDefaults`, `PlacementMode`, `Options`, `Input`,
|
||||
and `LayeredOptions` types. Explicit conversions remain appropriate at this
|
||||
boundary; consumers must not acquire engine type identity, fields, or methods.
|
||||
|
||||
Inside the engine, consolidate the equivalent `ImportDefaults` transport and
|
||||
private `importDefaults` records into one presence-aware input-defaults
|
||||
representation. Carry that representation through the ordinary and layered
|
||||
options, normalized request, and acquisition policy instead of translating the
|
||||
same five fields between equivalent engine records.
|
||||
|
||||
Keep `resolvedImportPolicy` distinct: it represents concrete per-declaration
|
||||
choices, including selector-specific rules, rather than another copy of optional
|
||||
load-wide input. Likewise, registration compilation and the normalized loading
|
||||
request have real responsibilities and are not replacement targets.
|
||||
|
||||
Make placement and defaults straightforward to construct on the existing engine
|
||||
option/input records. Remove decorator-style bridges made unnecessary by that
|
||||
construction surface. Exporting fields or types within `internal/engine` where
|
||||
needed by the root converter does not make them part of ConfigKit's supported
|
||||
public API. Keep explicit enum conversion and preserve unknown values until the
|
||||
existing option validation can report them; conversion must not silently choose
|
||||
a default or move validation into facade setters.
|
||||
|
||||
### Ownership And Copying
|
||||
|
||||
Make the actual mutable ownership boundaries identifiable. Public load calls
|
||||
continue to snapshot pointed-to default booleans and the pattern slice before
|
||||
source acquisition, as specified by the [public
|
||||
declaration](../../lifecycle.go). Preserve omitted, explicit false, nil pattern,
|
||||
and non-nil empty pattern as distinct inputs where they are distinct today.
|
||||
|
||||
Review the entire handoff, not just the duplicate type definitions: root
|
||||
conversion in [conversion.go](../../conversion.go), ordinary construction in
|
||||
[lifecycle.go](../../lifecycle.go), layered construction in
|
||||
[layered.go](../../layered.go), engine options and normalization, acquisition
|
||||
policy construction, and declaration resolution in
|
||||
[import_spec.go](../../internal/engine/import_spec.go).
|
||||
|
||||
Remove snapshots that merely recopy already-owned read-only defaults as they
|
||||
cross those private representations. Keep copies needed when establishing
|
||||
independent mutable ownership, including any independently owned resolved
|
||||
policy. The target is explainable ownership and less redundant work, not an exact
|
||||
copy count or removal of copying throughout the library. Shared defaults within
|
||||
one load can be read-only by implementation discipline; this creates no new
|
||||
caller immutability or concurrency requirement.
|
||||
|
||||
Preserve ordinary and layered option preflight before source reads and application
|
||||
hooks, independent declaration overrides, transitive/underlay propagation,
|
||||
selected-overlay behavior, destination normalization, and invalid-enum
|
||||
diagnostics. Keep callback, source, path, secret, and returned-record conversions
|
||||
whose ownership or domain meaning is not redundant transport.
|
||||
|
||||
The intended benefit is fewer concepts, field mappings, and construction paths
|
||||
to maintain. Reduced copying is useful but does not establish an end-to-end
|
||||
performance guarantee.
|
||||
|
||||
## Documentation Ownership
|
||||
|
||||
Internal documentation describes the resulting discovery and transport
|
||||
boundaries once implemented. Public declarations continue to own the existing
|
||||
snapshot and type contracts; this effort selects no new public behavior.
|
||||
The corresponding C03/F07 future-work entries serve as navigation to this
|
||||
roadmap rather than parallel target or progress inventories. Unrelated future
|
||||
candidates remain separate. The implementation plan owns the documentation
|
||||
updates and retirement of replaced private representations and helpers.
|
||||
|
||||
## Validation And Completion
|
||||
|
||||
Follow the [testing policy](../policy/testing.md) and [development
|
||||
guide](../development.md#baseline-validation). Reuse the existing glob selection,
|
||||
directory/link failure, import-default, placement, public facade, and dependency
|
||||
boundary coverage. Add a focused behavioral case only where a concrete
|
||||
extraction or ownership risk lacks protection.
|
||||
|
||||
For discovery, verify literal versus wildcard filters, host/native identity,
|
||||
contained and escaping links, optional absence versus read/close failures,
|
||||
ordering, exclusions, regular-file checks, and existing resource accounting. For
|
||||
transport, verify nil/empty and explicit-false distinctions, mutation of caller
|
||||
default storage after the load snapshot through a supported callback, invalid
|
||||
options before input consumption, both loading APIs, and transitive/underlay
|
||||
defaults. Existing conversion-focused tests may change with private construction;
|
||||
preserve the useful ownership behavior rather than fossilizing a helper's shape.
|
||||
|
||||
Completion requires an easier-to-follow selector, one engine input-defaults
|
||||
representation, fewer redundant mapping/construction sites, justified remaining
|
||||
copies, and preserved public behavior under the relevant checks. Review source
|
||||
and ownership flow as well as passing tests; moving complexity into a helper or
|
||||
renaming a duplicate record alone is insufficient.
|
||||
|
||||
No benchmark campaign, complexity score, allocation threshold, exact filesystem
|
||||
call trace, new resource limit, or additional release gate is required. This work
|
||||
adds no dependencies, caches, background activity, or new consumer controls, and
|
||||
does not reopen other audit findings or deferred performance experiments. Routine
|
||||
helper signatures, names, and file placement belong to the implementation plan;
|
||||
no significant product or architectural decision remains open for this scope.
|
||||
276
docs/roadmap/roadmap.md
Normal file
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](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#settled-scope-decisions).
|
||||
It is intended for maintainers reviewing the remediation's scope and architecture.
|
||||
|
||||
## Document Ownership
|
||||
|
||||
- [Archived audit](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md) records finding IDs, original evidence, detailed remedies and
|
||||
acceptance cases, and the settled scope decisions. Its evidence has not been
|
||||
independently reproduced by writing this roadmap.
|
||||
- This document owns the combined target state, work areas, and design intent.
|
||||
Finding IDs below provide traceability without repeating the full audit inventory.
|
||||
- The historical implementation plan at
|
||||
`bb5540b:docs/roadmap/implementation.md` records concrete stages, private
|
||||
implementation choices, edits, validation commands, and completion evidence.
|
||||
|
||||
The [configuration contract](../config.md), public Go declarations, and
|
||||
[architecture policy](../policy/architecture.md) remain the owners of behavior
|
||||
and guarantees. Update their applicable implementation documentation when work
|
||||
lands; this planning document does not redefine their contracts.
|
||||
|
||||
## Current Baseline And Targeted End State
|
||||
|
||||
The existing loading model remains the foundation. Public loading APIs delegate
|
||||
to the shared engine lifecycle; acquisition retains source scopes for composition,
|
||||
then precedence, inheritance, decoding, CLI overrides, application resolution,
|
||||
secret lookup, and validation produce a detached result with inspection metadata.
|
||||
The [component overview](../internal/overview.md) describes the current owners.
|
||||
|
||||
The audits identify failures at particular traversal, lifetime, error, and
|
||||
accounting boundaries, alongside redundant representations and repeated work.
|
||||
The target is to repair those boundaries and simplify their implementation while
|
||||
preserving ConfigKit's public API and configuration model.
|
||||
|
||||
| Current gap | Targeted outcome |
|
||||
| --- | --- |
|
||||
| Recursive descriptors can fail to terminate, failed dynamic builds can leave partial state, and slice aliases can be mistaken for cycles. | Traversal terminates for the reported cases, failed construction leaves reusable state valid, and supported acyclic values remain accepted. |
|
||||
| Callback and owned-root cleanup can be skipped during panic unwinding; directory errors lose their failure phase. | Cleanup follows the lifetime of each owned resource, and selected-directory failures retain the information needed for correct error handling. |
|
||||
| Finite symlink dependencies can be expanded repeatedly. | Completed dependencies are reused within a resolution while active cycles remain detectable. |
|
||||
| Projection reservations and published history can escape existing accounting. | Existing budgets govern the relevant allocations and complete retained document graph. |
|
||||
| Collection-policy work repeatedly scans or copies unrelated state. | Policy preparation, application, and retained evidence have clear ownership, with work localized to affected policy. |
|
||||
| Acquisition has redundant paths and difficult handoffs; several private representations have no production consumer. | Production paths and their tests share the active protocol, with cohesive handoffs and unused representations removed. |
|
||||
| Some lookups repeat full scans, and some tests assert incidental representation or miss meaningful mutations. | Local indexing avoids the identified repeated work, and tests protect observable contracts at useful boundaries. |
|
||||
|
||||
This is a bounded remediation effort, not a redesign of configuration semantics.
|
||||
Correcting erroneous acceptance, rejection, cleanup, or diagnostics in the
|
||||
reported cases restores existing contracts. It does not select new loading APIs,
|
||||
configuration options, limits, background work, or persistent caches.
|
||||
|
||||
## Selected Work
|
||||
|
||||
The following areas account for all 29 selected entries. Detailed examples and
|
||||
acceptance cases remain with their linked audit group.
|
||||
|
||||
### 1. Safe Typed-Value Traversal And Descriptor Reuse
|
||||
|
||||
Selected entries: **A01, A02, A03, A04, A05** in
|
||||
[schema and typed values](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#a-schema-and-typed-values).
|
||||
|
||||
Make pointer-only descriptor traversal terminate in decoding and path resolution,
|
||||
while preserving supported null values and ordinary recursive structures. Make
|
||||
dynamic descriptor construction transactional: a failed build must not leave
|
||||
partial descriptors available to subsequent operations. Staging successful
|
||||
additions or rolling back the complete failed addition set are implementation
|
||||
choices; both must handle recursive references and preserve prior valid entries.
|
||||
|
||||
Correct active-path cycle detection to distinguish finite slice views sharing
|
||||
storage from actual cycles. Keep detachment and nil/empty behavior intact across
|
||||
the callers that share this traversal machinery.
|
||||
|
||||
Enforce existing projection budgets before large container reservations. Include
|
||||
map-key materialization and overflow-safe lower-bound checks in the review; a
|
||||
check after the reservation does not address the reported failure. This work
|
||||
does not introduce a general heap limit or reject large containers independently
|
||||
of existing budgets.
|
||||
|
||||
Dynamic-type eligibility reuse depends on safe descriptor construction and retains
|
||||
completed successful checks within the value operation. Keep failure diagnostics
|
||||
specific to their current path and distinguish completed results from traversal visits.
|
||||
This removes repeated descriptor-graph walks without a cross-load cache.
|
||||
|
||||
### 2. Callback, Filesystem, And Dependency Lifetimes
|
||||
|
||||
Selected entries: **B01, B02** in [callback lifetime](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#b-callback-and-filesystem-lifetime)
|
||||
and **C01, C02** in [filesystem traversal](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#c-import-selection-and-link-resolution).
|
||||
|
||||
Tie callback-context deactivation to the callback invocation with deferred cleanup
|
||||
so it runs on normal return and panic unwinding. Independently give the owned
|
||||
import root a deferred cleanup fallback. Preserve normal close-error handling,
|
||||
caller ownership, and propagation of the original callback panic. Fixing either
|
||||
state lifetime or handle lifetime does not fix the other.
|
||||
|
||||
Preserve the phase of a directory failure. A genuinely absent directory before
|
||||
successful opening remains subject to existing permissiveness rules; a later
|
||||
read or close failure must not be treated as absence. Apply this distinction to
|
||||
both supported directory-reading paths. Strengthen the shared filesystem tests
|
||||
to check each handle's closure and structured, non-disclosing diagnostics,
|
||||
including partial reads and failures masked by another successful directory.
|
||||
Do not change regular-file close-error policy as a side effect.
|
||||
|
||||
Avoid repeatedly expanding completed finite symlink dependencies in both host
|
||||
and native filesystem resolution. Use operation-local memoization of completed
|
||||
targets, or an equivalent dependency evaluation, with keys that respect the
|
||||
resolution namespace. Keep active-path cycle detection separate from completed
|
||||
work. Preserve containment, supported finite reuse, and existing host-path
|
||||
semantics. The fix must work for directory and glob selection without a new
|
||||
arbitrary traversal cap or changed symlink defaults.
|
||||
|
||||
### 3. Retained History And Combination Evidence
|
||||
|
||||
Selected entries: **D01, D02, D03, D04** in
|
||||
[history and retained state](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#d-retained-history-and-unused-evidence).
|
||||
|
||||
Remove the unconsumed override-result pre-CLI copy and the unqueried published
|
||||
structural-input copy. Preserve the separate pre-CLI state used by active replay
|
||||
and the structural input still used before publication for metadata and secret
|
||||
classification. Remove unused combination-evidence members and the work needed
|
||||
solely to produce them, retaining projections, mappings, origins, and snapshots
|
||||
that have active consumers.
|
||||
|
||||
With retained roots clarified, enforce the existing history allowance against
|
||||
the complete graph published through inspection, including replay and inheritance
|
||||
state and publication-created copies. Count shared nodes once by pointer identity;
|
||||
distinct retained copies still consume the allowance. Account for required copies
|
||||
before creating them where practical. Removing an unused tree alone does not
|
||||
resolve the accounting omissions.
|
||||
|
||||
Keep required before/after evidence and existing inspection behavior. Validate
|
||||
the complete publication path with small controlled allowances, including the
|
||||
interaction of CLI, resolution replay, and inheritance, rather than relying only
|
||||
on tests of an isolated counting helper.
|
||||
|
||||
### 4. Collection-Policy Preparation And Application
|
||||
|
||||
Selected entries: **E01, E02, E03** in
|
||||
[collection policy and projection](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#e-collection-processing-and-efficiency).
|
||||
|
||||
Distinguish the existing policy state used for lookup, changes introduced by an
|
||||
incoming tier, and evidence retained by the assembly. Prepare incoming claims and
|
||||
declarations against read-only lower state, then apply the owned changes at the
|
||||
appropriate combination boundary. Avoid copying every unrelated policy for
|
||||
small tiers and scanning all policies for each independent replacement.
|
||||
|
||||
Use a semantic-path index, localized updates, or equivalent bounded lookup to
|
||||
find affected policy. Preserve retirement timing, policy established by the
|
||||
incoming contribution, pending declarations, persistent explicit modes, and
|
||||
nested record ownership. Copying remains appropriate wherever mutable ownership
|
||||
requires it. Removing only one duplicate clone or shortening a function does not
|
||||
address the identified growth patterns.
|
||||
|
||||
Clarify preparation, evidence application, and finalization responsibilities.
|
||||
Share straightforward history-descriptor translation where useful; evaluate
|
||||
shared record-selection logic while keeping overlay and inheritance origin and
|
||||
mode lifetimes distinct. Retain the common sequence-combination owner. This work
|
||||
does not call for a generic merging framework.
|
||||
|
||||
### 5. Active Acquisition Handoffs And Private Cleanup
|
||||
|
||||
Selected entries: **F01, F02, F03, F04, F05, F06** in
|
||||
[acquisition, cleanup, and test quality](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#f-remaining-maintenance-and-test-improvements).
|
||||
|
||||
Remove the test-only eager acquisition mode so acquisition retains scopes through
|
||||
the active production protocol. Tests that need composition should invoke the
|
||||
actual composition boundary explicitly. Retire the parallel test-only import
|
||||
parser and route relevant fixtures through the active directive parser or the
|
||||
appropriate declaration boundary, retaining malformed-input coverage.
|
||||
|
||||
Clarify active acquisition and coordinator handoffs around cohesive tasks:
|
||||
parsed-input directive/version handling, standalone placement, and repeated tier
|
||||
application/projection. Use explicit inputs and results consistent with existing
|
||||
state owners. Extracting helpers that continue to share surrounding mutable state
|
||||
does not establish a clearer handoff. Preserve ordinary/layered differences,
|
||||
overlay and diff behavior, source identity, callback timing, diagnostics, and
|
||||
accounting scopes.
|
||||
|
||||
Remove the disconnected root record-clone family, uncalled engine helpers, and
|
||||
unread private transport/result members identified in the audit. Reconfirm
|
||||
references on the implementation baseline, including function values and relevant
|
||||
build configurations. Preserve live conversion paths and validation side effects;
|
||||
the audit's absence of field reads is not a general rule for deleting state.
|
||||
Replace meaningful ownership coverage through the public boundary before removing
|
||||
tests that only exercise disconnected helpers.
|
||||
|
||||
### 6. Local Lookup Improvements
|
||||
|
||||
Selected entries: **E04, E05** in
|
||||
[collection policy and projection](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#e-collection-processing-and-efficiency)
|
||||
and **F08** in [secret-failure lookup](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#f08-index-secret-failure-location-queries-lazily).
|
||||
|
||||
Reuse a local document lookup or equivalent traversal for tier-history projection
|
||||
instead of rescanning wide mappings for each sequence. Preserve authored
|
||||
coordinates, sparse projections, and evidence ownership.
|
||||
|
||||
Restore compound identity fields using indexed mapping positions and membership
|
||||
checks rather than rescanning child and merged mappings for every field. Preserve
|
||||
child-authored values and locations, mapping order, and detached nodes.
|
||||
|
||||
Build secret-failure location lookup state lazily when failures need it. Preserve
|
||||
all diagnostics, nearest-location fallbacks, and confidentiality; successful
|
||||
loads should not incur an unnecessary index merely to support failure reporting.
|
||||
These changes address specific repeated work and do not promise a measured
|
||||
startup improvement.
|
||||
|
||||
### 7. Tests That Protect The Intended Behavior
|
||||
|
||||
Selected entries: **F09, F10, F11, F12** in
|
||||
[acquisition, cleanup, and test quality](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/453da684d7c7d3843a5f505f5c468b4c889dc6e2/docs/roadmap/audit.md#f-remaining-maintenance-and-test-improvements),
|
||||
in addition to the regression coverage attached to each reported defect.
|
||||
|
||||
Cover historical-path enumeration through the public API, including removed
|
||||
paths, queryability, ordering, and detached returned slices. Use those assertions
|
||||
to replace private representation checks where they address the same risk.
|
||||
|
||||
Strengthen fuzz detachment checks by mutating existing descendants or in-length
|
||||
elements, so spare capacity cannot hide aliases. Replace exact filesystem stat
|
||||
call sequences with observable candidate ordering and meaningful failure
|
||||
selection, retaining relevant assertions such as not opening excluded inputs.
|
||||
|
||||
Consolidate facade scaffolding around public construction, loading, errors,
|
||||
ownership, and lifecycle behavior. Preserve distinct integration risks and
|
||||
reader-byte snapshot coverage. Existing coverage percentages or a desired test
|
||||
count are not acceptance criteria for this work.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
The historical implementation plan at `bb5540b:docs/roadmap/implementation.md`
|
||||
defines the sequential, Luna-scoped stages and their dependencies, including
|
||||
coordinated changes to descriptor state, history ownership/accounting, collection
|
||||
policy, acquisition, and replacement public coverage. It also records validation
|
||||
commands and the complete finding-to-stage map. The audit remains the owner of
|
||||
original evidence and detailed acceptance cases.
|
||||
|
||||
## Validation And Completion
|
||||
|
||||
Validation follows the audit's detailed acceptance cases and the
|
||||
[testing policy](../policy/testing.md). For fatal or hanging regressions, use bounded
|
||||
subprocesses; for budget cases, use small controlled limits. For symlink expansion
|
||||
and repeated lookup work, prefer bounded operation evidence and algorithm review
|
||||
over timing thresholds. Use real filesystem fixtures where filesystem semantics
|
||||
matter and narrow fault injection for read/close failures. State platform limits.
|
||||
|
||||
Applicable development checks come from the [development guide](../development.md),
|
||||
including appropriate race coverage for changed ownership and lifetime boundaries.
|
||||
The historical implementation plan owns their execution. Existing audit results
|
||||
are baseline evidence, not validation of the eventual changes. No performance
|
||||
benchmark campaign is required by this roadmap, and no new benchmark results are
|
||||
claimed.
|
||||
|
||||
Completion means all 29 selected findings have implementation and acceptance
|
||||
evidence, the affected public behavior remains consistent with its contracts,
|
||||
and canonical internal documentation describes the resulting owners. The
|
||||
historical implementation plan records execution status without duplicate
|
||||
progress inventories. Retire completed planning records under the normal
|
||||
documentation lifecycle.
|
||||
|
||||
## Scope Boundaries And Decision Status
|
||||
|
||||
All three audit scope questions are settled. No significant architectural or
|
||||
product decision remains open for this roadmap. The historical implementation
|
||||
plan records private design choices and sequencing, subject to the acceptance
|
||||
cases above.
|
||||
If that work exposes a need to change a public contract, bring the specific
|
||||
decision back for review rather than treating it as an implicit part of remediation.
|
||||
|
||||
The glob preparation refactor (C03) and wider facade transport simplification
|
||||
(F07) have since been selected in the
|
||||
[internal-boundary roadmap](internal-boundaries.md). Other deferred features,
|
||||
clone-traversal redesign, and broader performance experiments remain outside
|
||||
this effort. Keep
|
||||
existing ownership boundaries and loading APIs; no ADR or architecture-policy
|
||||
change is proposed by this roadmap.
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
|
||||
func TestFacadeCompatibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
for _, candidate := range []struct {
|
||||
name string
|
||||
type_ reflect.Type
|
||||
}{
|
||||
{"Path", reflect.TypeFor[configkit.Path]()},
|
||||
{"Source", reflect.TypeFor[configkit.Source]()},
|
||||
{"Secret", reflect.TypeFor[configkit.Secret]()},
|
||||
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
|
||||
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
|
||||
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
|
||||
} {
|
||||
if candidate.type_.PkgPath() != packagePath {
|
||||
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
|
||||
}
|
||||
}
|
||||
|
||||
constructed := configkit.Options[facadeCompatibilityConfig]{
|
||||
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
||||
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
||||
@@ -56,11 +38,6 @@ func TestFacadeCompatibility(t *testing.T) {
|
||||
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
||||
t.Fatalf("constructed override path = %q, want /value", got)
|
||||
}
|
||||
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
|
||||
if publicResult.Config.Value != "constructed" {
|
||||
t.Fatal("constructed result lost its public configuration")
|
||||
}
|
||||
|
||||
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
||||
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
||||
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
||||
|
||||
@@ -23,6 +23,9 @@ type equivalentConfig struct {
|
||||
|
||||
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
||||
bytesData := []byte(yaml)
|
||||
bytesSource := configkit.Bytes("memory", bytesData)
|
||||
bytesData[0] = 'X'
|
||||
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
||||
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||
}{
|
||||
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
||||
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
||||
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes},
|
||||
{"bytes", bytesSource, context, configkit.SourceKindBytes},
|
||||
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
||||
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
||||
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
||||
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
||||
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||
t.Fatalf("reader re-consumption = %s", got)
|
||||
}
|
||||
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
|
||||
readerCopy := copiedReader
|
||||
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
|
||||
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||
t.Fatalf("copied reader re-consumption = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
||||
|
||||
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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
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 {
|
||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
||||
func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
Imported string `yaml:"imported"`
|
||||
}
|
||||
for _, layered := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
|
||||
configkit:
|
||||
imports:
|
||||
- dir: recursive-files
|
||||
- dir: parts
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
||||
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
|
||||
|
||||
recursive := true
|
||||
patterns := []string{"*.cfg"}
|
||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
||||
definition := Definition[config]{Version: &VersionRule{
|
||||
Path: Keys("version"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
if version != (Version{Present: true, Text: "v1"}) {
|
||||
t.Errorf("version callback received %#v", version)
|
||||
}
|
||||
recursive = false
|
||||
patterns[0] = "["
|
||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
||||
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 (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
|
||||
293
internal/engine/acquisition.go
Normal file
293
internal/engine/acquisition.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package engine
|
||||
|
||||
import "strconv"
|
||||
|
||||
type acquisitionPolicy struct {
|
||||
importDefaults ImportDefaults
|
||||
sequencePolicy sequencePolicy
|
||||
importLimits importLimits
|
||||
}
|
||||
|
||||
type acquisitionState struct {
|
||||
accounting *importAccounting
|
||||
versionState *sourceVersionState
|
||||
}
|
||||
|
||||
type tierAcquisitionState struct {
|
||||
visited map[resourceIdentity]importRoute
|
||||
hostToken *namespaceToken
|
||||
}
|
||||
|
||||
type inputAcquisitionRequest struct {
|
||||
input loadInput
|
||||
role sourceVersionRole
|
||||
directiveContext rootDirectiveContext
|
||||
protectionRule *VersionRule
|
||||
ordinaryOverlay string
|
||||
}
|
||||
|
||||
type importAcquisitionRequest struct {
|
||||
input inputAcquisitionRequest
|
||||
main parsedSource
|
||||
processing *sourceVersionProcessingResult
|
||||
}
|
||||
|
||||
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||
source := request.input.source
|
||||
destination := request.input.destination
|
||||
accounting := state.accounting
|
||||
versionState := state.versionState
|
||||
sourceID := "source-" + strconv.Itoa(accounting.sources+1)
|
||||
selectedSource := source
|
||||
if source.kind == sourceSearch {
|
||||
resolved, failedSource, diagnostics := resolveSearchInput(source, sourceID, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe())
|
||||
if len(diagnostics) != 0 {
|
||||
if failedSource != nil {
|
||||
failedSource.Destination = destination.clone()
|
||||
failedSource.placementMode = request.input.placementMode
|
||||
}
|
||||
return assembledInput{source: resolved, sources: sourceInfos(failedSource), versionState: versionState, accounting: accounting}, diagnostics
|
||||
}
|
||||
selectedSource = resolved
|
||||
if request.input.filesystem != nil {
|
||||
if diagnostics := filesystemOptionDiagnostics(selectedSource, request.input.filesystem); len(diagnostics) != 0 {
|
||||
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
|
||||
}
|
||||
}
|
||||
if selectedSource.kind == sourceNone && activeOverlay(request.ordinaryOverlay) && request.input.filesystem == nil {
|
||||
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
|
||||
}
|
||||
}
|
||||
limits := policy.importLimits
|
||||
parseLimits := defaultParseLimits()
|
||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||
parsed, diagnostics := parseSource(selectedSource, sourceID, parseLimits)
|
||||
if parsed.source != nil {
|
||||
parsed.source.Destination = destination.clone()
|
||||
parsed.source.placementMode = request.input.placementMode
|
||||
}
|
||||
input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
||||
if len(diagnostics) != 0 {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
// Mapping value issues already identify application content. Other early
|
||||
// failures describe source syntax or access, not a placed value.
|
||||
if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 {
|
||||
for index := range diagnostics {
|
||||
relative := diagnostics[index].Path
|
||||
segments := clonePathSegments(destination.segments, len(relative.segments))
|
||||
diagnostics[index].Path = Path{segments: append(segments, relative.segments...)}
|
||||
}
|
||||
}
|
||||
return input, diagnostics
|
||||
}
|
||||
directiveContext := request.directiveContext
|
||||
if directiveContext == 0 {
|
||||
directiveContext = rootDirectiveContextImportsOnly
|
||||
}
|
||||
if !request.input.imports && parsed.document != nil {
|
||||
var directiveDiagnostics []Diagnostic
|
||||
if directiveContext == rootDirectiveContextSource || directiveContext == rootDirectiveContextLayered {
|
||||
directiveDiagnostics = disabledRootImportDiagnostics(parsed.document)
|
||||
} else {
|
||||
directiveDiagnostics = disabledDirectiveDiagnostics(parsed.document, Path{})
|
||||
}
|
||||
if len(directiveDiagnostics) != 0 {
|
||||
return input, directiveDiagnostics
|
||||
}
|
||||
}
|
||||
request.directiveContext = directiveContext
|
||||
versionProcessing, diagnostics := processAcquiredInputVersion(parsed, request, versionState)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
request.input.source = selectedSource
|
||||
if request.input.imports && selectedSource.kind != sourceNone {
|
||||
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
||||
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if input.composition != nil {
|
||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||
input.document = input.composition.root
|
||||
}
|
||||
} else {
|
||||
input, diagnostics = acquireStandaloneInput(input, request, versionProcessing, policy, tier)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
}
|
||||
return completeAcquisitionContexts(input, request, tier)
|
||||
}
|
||||
|
||||
func acquireStandaloneInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||
parsed := input.parsed
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if processing != nil {
|
||||
input.document = processing.payload
|
||||
}
|
||||
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.resolvedFilesystem = &resolved
|
||||
}
|
||||
if request.input.imports || input.document == nil {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
limits := policy.importLimits
|
||||
placement := request.input.destination
|
||||
documentLimits := defaultParseLimits().document
|
||||
_, placed, err := placeDocumentWithMode(input.document, placement, request.input.placementMode, input.document.Location, documentLimits.Depth, documentLimits.Nodes)
|
||||
if err != nil {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
}
|
||||
localPayload := input.document
|
||||
input.document = placed
|
||||
identity := importKeyForMainSource(parsed.source)
|
||||
var sequenceDeclarations []sequenceDeclarationSpec
|
||||
if processing != nil {
|
||||
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
|
||||
}
|
||||
input.acquisition = []acquisitionScope{{
|
||||
owner: identity,
|
||||
destination: placement.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||
document: placed, destination: placement.clone(), processing: processing,
|
||||
sequenceDeclarations: sequenceDeclarations,
|
||||
}},
|
||||
}}
|
||||
rawNodes := countInputNodes(parsed.document)
|
||||
placementNodes := destinationWrapperNodeCountForMode(placement, request.input.placementMode)
|
||||
accounting := input.accounting
|
||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||
}
|
||||
if diagnostic := registerAcquiredSource(input.source, request.input.filesystem, parsed.source, tier.visited, tier.hostToken); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += parsed.bytes
|
||||
accounting.nodes += rawNodes + placementNodes
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func completeAcquisitionContexts(input assembledInput, request inputAcquisitionRequest, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||
if activeOverlay(request.ordinaryOverlay) && !request.input.imports {
|
||||
return input, nil
|
||||
}
|
||||
if input.resolvedFilesystem == nil && len(input.sources) != 0 && (input.source.kind == sourceFile || request.input.filesystem != nil || nativeContext(input.source, request.input.filesystem)) {
|
||||
resolved, diagnostic := resolveFilesystemContext(input.source, request.input.filesystem, input.parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.resolvedFilesystem = &resolved
|
||||
}
|
||||
contexts, diagnostic := contextsForResolvedSources(input.resolvedFilesystem, input.sources)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.contexts = contexts
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func processAcquiredInputVersion(parsed parsedSource, request inputAcquisitionRequest, state *sourceVersionState) (*sourceVersionProcessingResult, []Diagnostic) {
|
||||
sourceID := ""
|
||||
if parsed.source != nil {
|
||||
sourceID = parsed.source.ID
|
||||
}
|
||||
|
||||
var result sourceVersionProcessingResult
|
||||
hasResult := false
|
||||
updateMainProcessing := false
|
||||
switch request.directiveContext {
|
||||
case rootDirectiveContextSource:
|
||||
if state != nil {
|
||||
processed, diagnostics := state.processWithDirectiveContext(
|
||||
parsed.document, request.role, request.input.destination, sourceID,
|
||||
request.input.placementMode, request.directiveContext,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
result = processed
|
||||
updateMainProcessing = request.role == sourceVersionMain
|
||||
} else {
|
||||
result = processSourceVersionWithDirectiveContext(
|
||||
parsed.document, request.protectionRule, request.role,
|
||||
request.input.destination, sourceVersionModeDefault, request.directiveContext,
|
||||
)
|
||||
if len(result.diagnostics) != 0 {
|
||||
return nil, result.diagnostics
|
||||
}
|
||||
}
|
||||
hasResult = true
|
||||
case rootDirectiveContextLayered:
|
||||
extracted, diagnostics := extractRootDirectives(parsed.document, request.directiveContext)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
result = sourceVersionProcessingResult{
|
||||
payload: extracted.body, imports: extracted.imports, sequences: extracted.sequences,
|
||||
directiveContext: request.directiveContext,
|
||||
}
|
||||
hasResult = true
|
||||
if state != nil && state.mode != sourceVersionModeDefault {
|
||||
processed, diagnostics := state.process(
|
||||
extracted.body, request.role, request.input.destination,
|
||||
sourceID, request.input.placementMode,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
processed.imports = extracted.imports
|
||||
processed.sequences = append(cloneSequenceDeclarations(extracted.sequences), processed.sequences...)
|
||||
processed.directiveContext = request.directiveContext
|
||||
result = processed
|
||||
updateMainProcessing = request.role == sourceVersionMain
|
||||
}
|
||||
default:
|
||||
if state != nil && state.mode != sourceVersionModeDefault {
|
||||
processed, diagnostics := state.process(
|
||||
parsed.document, request.role, request.input.destination,
|
||||
sourceID, request.input.placementMode,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
result = processed
|
||||
hasResult = true
|
||||
updateMainProcessing = request.role == sourceVersionMain
|
||||
}
|
||||
}
|
||||
if !hasResult {
|
||||
return nil, nil
|
||||
}
|
||||
if updateMainProcessing {
|
||||
completed := result
|
||||
state.mainProcessing = &completed
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
|
||||
if visited == nil || info == nil || info.Path == "" {
|
||||
return nil
|
||||
}
|
||||
identity := resourceIdentity{namespace: hostToken, path: info.Path}
|
||||
if source.kind == sourceFilesystem {
|
||||
identity.namespace = source.namespace.token
|
||||
} else if context != nil && context.FS != nil {
|
||||
identity.namespace = context.FS.namespace.token
|
||||
}
|
||||
if previous, repeated := visited[identity]; repeated {
|
||||
value := importRepeatedDiagnostic(Location{SourceID: info.ID}, previous, importRoute{})
|
||||
return &value
|
||||
}
|
||||
visited[identity] = importRoute{canonical: info.Path}
|
||||
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 {
|
||||
policy collectionPolicy
|
||||
operation sequenceMode
|
||||
declarations []sequenceDeclarationSpec
|
||||
declarationsPresent bool
|
||||
identityDeclarationPresent bool
|
||||
modeDeclarationPresent bool
|
||||
applicationIdentityFields []string
|
||||
applicationIdentityPresent bool
|
||||
effectiveIdentityFields []string
|
||||
}
|
||||
@@ -60,9 +56,6 @@ func resolveCollectionPolicy(
|
||||
}
|
||||
}
|
||||
result := collectionPolicyResolution{
|
||||
declarations: normalizedDeclarations,
|
||||
declarationsPresent: len(normalizedDeclarations) != 0,
|
||||
applicationIdentityFields: normalizedApplicationIdentityFields,
|
||||
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
||||
}
|
||||
|
||||
@@ -73,7 +66,6 @@ func resolveCollectionPolicy(
|
||||
var declaredModeLocation *Location
|
||||
for _, declaration := range normalizedDeclarations {
|
||||
if declaration.identityPresent {
|
||||
result.identityDeclarationPresent = true
|
||||
if declaredIdentityFields == nil {
|
||||
declaredIdentityFields = cloneStrings(declaration.identityFields)
|
||||
declaredIdentityLocation = collectionDeclarationLocation(declaration.identityLocation, declaration.location)
|
||||
|
||||
137
internal/engine/collection_policy_index.go
Normal file
137
internal/engine/collection_policy_index.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package engine
|
||||
|
||||
// collectionPolicyRetirementIndex locates only assembly policies whose YAML
|
||||
// identity or record-owned state can be cleared by an ancestor replacement.
|
||||
// Mapping keys and sequence indexes remain distinct through pathSegment keys.
|
||||
type collectionPolicyRetirementIndex struct {
|
||||
children map[pathSegment]*collectionPolicyRetirementIndex
|
||||
entryKey string
|
||||
hasEntry bool
|
||||
}
|
||||
|
||||
func newCollectionPolicyRetirementIndex(scope collectionPolicyScope) *collectionPolicyRetirementIndex {
|
||||
index := &collectionPolicyRetirementIndex{}
|
||||
for _, entry := range scope.collections {
|
||||
if collectionPolicyHasRetirementState(entry.policy) {
|
||||
index.set(entry)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) set(entry collectionPolicyEntry) {
|
||||
node := index
|
||||
for _, segment := range entry.destination.segments {
|
||||
if node.children == nil {
|
||||
node.children = make(map[pathSegment]*collectionPolicyRetirementIndex)
|
||||
}
|
||||
if node.children[segment] == nil {
|
||||
node.children[segment] = &collectionPolicyRetirementIndex{}
|
||||
}
|
||||
node = node.children[segment]
|
||||
}
|
||||
node.entryKey = entry.destination.String()
|
||||
node.hasEntry = true
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) remove(destination Path) {
|
||||
if index == nil {
|
||||
return
|
||||
}
|
||||
nodes := make([]*collectionPolicyRetirementIndex, 1, len(destination.segments)+1)
|
||||
nodes[0] = index
|
||||
node := index
|
||||
for _, segment := range destination.segments {
|
||||
node = node.children[segment]
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
node.entryKey = ""
|
||||
node.hasEntry = false
|
||||
pruneCollectionPolicyIndex(nodes, destination.segments)
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) retire(scope *collectionPolicyScope, ancestor Path) {
|
||||
if index == nil || scope == nil {
|
||||
return
|
||||
}
|
||||
nodes := make([]*collectionPolicyRetirementIndex, 1, len(ancestor.segments)+1)
|
||||
nodes[0] = index
|
||||
node := index
|
||||
for _, segment := range ancestor.segments {
|
||||
node = node.children[segment]
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
index.clearSubtree(scope, node)
|
||||
pruneCollectionPolicyIndex(nodes, ancestor.segments)
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) clearSubtree(scope *collectionPolicyScope, node *collectionPolicyRetirementIndex) {
|
||||
if node.hasEntry {
|
||||
if entry, found := scope.collections[node.entryKey]; found {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
scope.collections[node.entryKey] = entry
|
||||
}
|
||||
node.entryKey = ""
|
||||
node.hasEntry = false
|
||||
}
|
||||
for segment, child := range node.children {
|
||||
index.clearSubtree(scope, child)
|
||||
if child.empty() {
|
||||
delete(node.children, segment)
|
||||
}
|
||||
}
|
||||
if len(node.children) == 0 {
|
||||
node.children = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (index *collectionPolicyRetirementIndex) empty() bool {
|
||||
return index == nil || !index.hasEntry && len(index.children) == 0
|
||||
}
|
||||
|
||||
func pruneCollectionPolicyIndex(nodes []*collectionPolicyRetirementIndex, segments []pathSegment) {
|
||||
for position := len(segments) - 1; position >= 0; position-- {
|
||||
child := nodes[position+1]
|
||||
if !child.empty() {
|
||||
return
|
||||
}
|
||||
delete(nodes[position].children, segments[position])
|
||||
}
|
||||
}
|
||||
|
||||
func collectionPolicyHasRetirementState(policy collectionPolicy) bool {
|
||||
return policy.identityFields != nil || policy.identityPresent || policy.identityLocation != nil ||
|
||||
policy.recordOrder != nil || policy.recordPolicies != nil
|
||||
}
|
||||
|
||||
func (assembly *assemblyAccumulator) setCollectionPolicy(destination Path, policy collectionPolicy) {
|
||||
if assembly == nil || assembly.current == nil {
|
||||
return
|
||||
}
|
||||
if assembly.collectionPolicyRetirement == nil {
|
||||
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||
}
|
||||
assembly.current.collectionPolicies.set(destination, policy)
|
||||
entry := assembly.current.collectionPolicies.collections[destination.String()]
|
||||
if collectionPolicyHasRetirementState(entry.policy) {
|
||||
assembly.collectionPolicyRetirement.set(entry)
|
||||
} else {
|
||||
assembly.collectionPolicyRetirement.remove(destination)
|
||||
}
|
||||
}
|
||||
|
||||
func (assembly *assemblyAccumulator) retireCollectionPoliciesAtOrBelow(ancestor Path) {
|
||||
if assembly == nil || assembly.current == nil {
|
||||
return
|
||||
}
|
||||
if assembly.collectionPolicyRetirement == nil {
|
||||
assembly.collectionPolicyRetirement = newCollectionPolicyRetirementIndex(assembly.current.collectionPolicies)
|
||||
}
|
||||
assembly.collectionPolicyRetirement.retire(&assembly.current.collectionPolicies, ancestor)
|
||||
}
|
||||
@@ -21,8 +21,8 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if !resolved.declarationsPresent || !resolved.identityDeclarationPresent || !resolved.modeDeclarationPresent {
|
||||
t.Fatalf("declaration presence was lost: %#v", resolved)
|
||||
if !resolved.modeDeclarationPresent {
|
||||
t.Fatalf("mode declaration was lost: %#v", resolved)
|
||||
}
|
||||
wantFields := []string{"id", "namespace"}
|
||||
if !reflect.DeepEqual(resolved.policy.identityFields, wantFields) || !reflect.DeepEqual(resolved.effectiveIdentityFields, wantFields) {
|
||||
@@ -31,9 +31,6 @@ func TestResolveCollectionPolicyReconcilesDeclarationsBeforeComposition(t *testi
|
||||
if !resolved.policy.identityPresent || !resolved.policy.explicitModePresent || resolved.policy.explicitMode != sequenceModeAppend || resolved.operation != sequenceModeAppend {
|
||||
t.Fatalf("resolved explicit policy = %#v, operation %d", resolved.policy, resolved.operation)
|
||||
}
|
||||
if len(resolved.declarations) != 3 || resolved.declarations[0].identityLocation == nil || resolved.declarations[2].location.Line == 0 {
|
||||
t.Fatalf("declaration locations were not retained: %#v", resolved.declarations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollectionPolicyRejectsConflictingPeersAtBothSites(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,23 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
// operation at that exact path.
|
||||
type collectionReplacementPaths map[string]bool
|
||||
|
||||
type lowerCollectionPolicyView struct {
|
||||
scope collectionPolicyScope
|
||||
replacements collectionReplacementPaths
|
||||
}
|
||||
|
||||
func (view lowerCollectionPolicyView) at(path Path) (collectionPolicy, bool) {
|
||||
entry, found := view.scope.collections[path.String()]
|
||||
if !found {
|
||||
return collectionPolicy{}, false
|
||||
}
|
||||
policy := entry.policy
|
||||
if view.replacements.contains(path, true) {
|
||||
clearCollectionIdentity(&policy)
|
||||
}
|
||||
return policy, true
|
||||
}
|
||||
|
||||
func (paths collectionReplacementPaths) contains(path Path, includeExact bool) bool {
|
||||
for length := 0; length <= len(path.segments); length++ {
|
||||
prefix := Path{segments: path.segments[:length]}
|
||||
@@ -27,6 +44,26 @@ func lowerCollectionPoliciesForClaims(
|
||||
declarations map[string][]sequenceDeclarationSpec,
|
||||
application sequencePolicy,
|
||||
) (collectionPolicyScope, collectionReplacementPaths) {
|
||||
replaced := collectionReplacementPathsForClaims(lower, claims, declarations, application)
|
||||
// This materialization is used when inheritance creates an independently
|
||||
// owned local scope. Strict tier resolution uses lowerCollectionPolicyView
|
||||
// instead and keeps only its sparse current-scope result.
|
||||
result := cloneCollectionPolicyScope(lower)
|
||||
for key, entry := range result.collections {
|
||||
if replaced.contains(entry.destination, true) {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
result.collections[key] = entry
|
||||
}
|
||||
}
|
||||
return result, replaced
|
||||
}
|
||||
|
||||
func collectionReplacementPathsForClaims(
|
||||
lower collectionPolicyScope,
|
||||
claims []compositionClaim,
|
||||
declarations map[string][]sequenceDeclarationSpec,
|
||||
application sequencePolicy,
|
||||
) collectionReplacementPaths {
|
||||
byPath := make(map[string]compositionClaim, len(claims))
|
||||
rootPath := ""
|
||||
if len(claims) != 0 {
|
||||
@@ -62,14 +99,7 @@ func lowerCollectionPoliciesForClaims(
|
||||
replaced[key] = false
|
||||
}
|
||||
}
|
||||
result := cloneCollectionPolicyScope(lower)
|
||||
for key, entry := range result.collections {
|
||||
if replaced.contains(entry.destination, true) {
|
||||
clearCollectionIdentity(&entry.policy)
|
||||
result.collections[key] = entry
|
||||
}
|
||||
}
|
||||
return result, replaced
|
||||
return replaced
|
||||
}
|
||||
|
||||
func clearCollectionIdentity(policy *collectionPolicy) {
|
||||
|
||||
@@ -2,7 +2,10 @@ package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestSourceReplacementRetiresIdentityForEveryValueKind(t *testing.T) {
|
||||
@@ -109,3 +112,239 @@ func TestReplacementRetirementPreservesApplicationConstraintsAndRetainedKeys(t *
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblyPolicyRetirementUsesSemanticPathSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
var policies collectionPolicyScope
|
||||
slashPath := Keys("branch/name", "items")
|
||||
separateKeysPath := Keys("branch", "name", "items")
|
||||
numericKeyPath := Keys("items", "0", "named")
|
||||
sequenceIndexPath := Keys("items").Index(0).Key("indexed")
|
||||
policies.set(slashPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(separateKeysPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(numericKeyPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(sequenceIndexPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
|
||||
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("branch/name"))
|
||||
assertRetirement := func(path Path, identityPresent bool) {
|
||||
t.Helper()
|
||||
policy, found := assembly.current.collectionPolicies.at(path)
|
||||
if !found || policy.identityPresent != identityPresent {
|
||||
t.Fatalf("policy at %s = %#v (found %t), want identity present %t", path, policy, found, identityPresent)
|
||||
}
|
||||
}
|
||||
assertRetirement(slashPath, false)
|
||||
assertRetirement(separateKeysPath, true)
|
||||
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("items", "0"))
|
||||
assertRetirement(numericKeyPath, false)
|
||||
assertRetirement(sequenceIndexPath, true)
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("items").Index(0))
|
||||
assertRetirement(sequenceIndexPath, false)
|
||||
}
|
||||
|
||||
func TestAssemblyPolicyRetirementPrunesAndReindexesAroundModeOnlyEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
identityPath := Keys("catalog", "items")
|
||||
modePath := Keys("catalog", "settings")
|
||||
var policies collectionPolicyScope
|
||||
policies.set(identityPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
policies.set(modePath, collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true})
|
||||
assembly := newAssemblyAccumulator(&composedDocument{collectionPolicies: policies})
|
||||
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
retired, found := assembly.current.collectionPolicies.at(identityPath)
|
||||
if !found || retired.identityPresent || !assembly.collectionPolicyRetirement.empty() {
|
||||
t.Fatalf("first retirement left identity or stale index: %#v, index %#v", retired, assembly.collectionPolicyRetirement)
|
||||
}
|
||||
mode, found := assembly.current.collectionPolicies.at(modePath)
|
||||
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
|
||||
t.Fatalf("mode-only policy changed during retirement: %#v, found %t", mode, found)
|
||||
}
|
||||
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
newPath := Keys("catalog", "items", "nested")
|
||||
assembly.setCollectionPolicy(newPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
|
||||
if assembly.collectionPolicyRetirement.empty() {
|
||||
t.Fatal("new identity-bearing policy was not reindexed")
|
||||
}
|
||||
assembly.retireCollectionPoliciesAtOrBelow(Keys("catalog"))
|
||||
newPolicy, found := assembly.current.collectionPolicies.at(newPath)
|
||||
if !found || newPolicy.identityPresent || !assembly.collectionPolicyRetirement.empty() {
|
||||
t.Fatalf("repeated ancestor retirement left new identity or stale index: %#v, index %#v", newPolicy, assembly.collectionPolicyRetirement)
|
||||
}
|
||||
mode, found = assembly.current.collectionPolicies.at(modePath)
|
||||
if !found || mode.explicitMode != sequenceModeAppend || !mode.explicitModePresent {
|
||||
t.Fatalf("repeated retirement changed retained mode: %#v, found %t", mode, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisjointScalarReplacementsRetainIndexedCollectionPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
const policyCount = 128
|
||||
const replacementCount = 32
|
||||
|
||||
var policies collectionPolicyScope
|
||||
collectionEntries := make([]document.Entry, 0, policyCount)
|
||||
for index := 0; index < policyCount; index++ {
|
||||
key := fmt.Sprintf("collection-%03d", index)
|
||||
path := Keys("collections", key)
|
||||
policies.set(path, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
collectionEntries = append(collectionEntries, document.Entry{
|
||||
Key: key, Value: &document.Node{Kind: document.Sequence},
|
||||
})
|
||||
}
|
||||
|
||||
baseScalars := make([]document.Entry, 0, replacementCount)
|
||||
incomingScalars := make([]document.Entry, 0, replacementCount)
|
||||
for index := 0; index < replacementCount; index++ {
|
||||
key := fmt.Sprintf("scalar-%03d", index)
|
||||
baseScalars = append(baseScalars, document.Entry{
|
||||
Key: key, Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "previous", Value: &document.Node{Kind: document.Boolean, Text: "true"},
|
||||
}}},
|
||||
})
|
||||
incomingScalars = append(incomingScalars, document.Entry{
|
||||
Key: key, Value: &document.Node{Kind: document.String, Text: "replaced"},
|
||||
})
|
||||
}
|
||||
base := &composedDocument{
|
||||
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||
{Key: "collections", Value: &document.Node{Kind: document.Mapping, Mapping: collectionEntries}},
|
||||
{Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: baseScalars}},
|
||||
}},
|
||||
collectionPolicies: policies,
|
||||
origins: make(map[string]Origin),
|
||||
}
|
||||
incoming := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "scalars", Value: &document.Node{Kind: document.Mapping, Mapping: incomingScalars},
|
||||
}}}
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
incoming, nil, collectionPolicyScope{}, nil, LayerOverlay, sequencePolicy{}, nil,
|
||||
); len(diagnostics) != 0 {
|
||||
t.Fatalf("apply disjoint scalar replacements: %#v", diagnostics)
|
||||
}
|
||||
for index := 0; index < policyCount; index++ {
|
||||
path := Keys("collections", fmt.Sprintf("collection-%03d", index))
|
||||
policy, found := assembly.current.collectionPolicies.at(path)
|
||||
if !found || !policy.identityPresent || len(policy.identityFields) != 1 || policy.identityFields[0] != "id" {
|
||||
t.Fatalf("disjoint replacement changed policy %s: %#v, found %t", path, policy, found)
|
||||
}
|
||||
}
|
||||
for index := 0; index < replacementCount; index++ {
|
||||
path := Keys("scalars", fmt.Sprintf("scalar-%03d", index))
|
||||
value, found := documentAtPath(assembly.current.root, path)
|
||||
if !found || value.Kind != document.String || value.Text != "replaced" {
|
||||
t.Fatalf("scalar replacement at %s = %#v, found %t", path, value, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisjointTierOverlaysRetainNestedCollectionPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nestedOwner := newCollectionRecordPolicy()
|
||||
nestedOwner.scope.set(Keys("prompts"), collectionPolicy{
|
||||
identityFields: []string{"name"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
itemsPolicy := collectionPolicyWithRecords(collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
}, nestedOwner)
|
||||
var policies collectionPolicyScope
|
||||
policies.set(Keys("items"), itemsPolicy)
|
||||
policies.set(Keys("keyOnly"), collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
})
|
||||
policies.set(Keys("modeOnly"), collectionPolicy{
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
|
||||
base := &composedDocument{
|
||||
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||
{Key: "items", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{{
|
||||
Kind: document.Mapping,
|
||||
Mapping: []document.Entry{
|
||||
{Key: "id", Value: &document.Node{Kind: document.String, Text: "one"}},
|
||||
{Key: "prompts", Value: &document.Node{Kind: document.Sequence}},
|
||||
},
|
||||
}}}},
|
||||
{Key: "keyOnly", Value: &document.Node{Kind: document.Sequence}},
|
||||
{Key: "modeOnly", Value: &document.Node{Kind: document.Sequence}},
|
||||
}},
|
||||
collectionPolicies: policies,
|
||||
origins: make(map[string]Origin),
|
||||
}
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
initialItems, found := assembly.current.collectionPolicies.at(Keys("items"))
|
||||
if !found {
|
||||
t.Fatal("initial item policy is missing")
|
||||
}
|
||||
initialRecord, found := initialItems.recordPolicyAt(0)
|
||||
if !found {
|
||||
t.Fatal("initial nested record owner is missing")
|
||||
}
|
||||
|
||||
for index, key := range []string{"firstTier", "secondTier", "thirdTier"} {
|
||||
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: key, Value: &document.Node{Kind: document.Boolean, Text: "true"},
|
||||
}}}
|
||||
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
|
||||
); len(diagnostics) != 0 {
|
||||
t.Fatalf("apply disjoint tier %d: %#v", index, diagnostics)
|
||||
}
|
||||
|
||||
retainedItems, found := assembly.current.collectionPolicies.at(Keys("items"))
|
||||
if !found || !retainedItems.identityPresent || len(retainedItems.identityFields) != 1 || retainedItems.identityFields[0] != "id" ||
|
||||
!retainedItems.explicitModePresent || retainedItems.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("tier %d changed retained item policy: %#v, found %t", index, retainedItems, found)
|
||||
}
|
||||
retainedRecord, found := retainedItems.recordPolicyAt(0)
|
||||
if !found || retainedRecord.owner != initialRecord.owner {
|
||||
t.Fatalf("tier %d replaced the nested record owner: %#v, found %t", index, retainedRecord, found)
|
||||
}
|
||||
nested, found := retainedRecord.scope.at(Keys("prompts"))
|
||||
if !found || !nested.identityPresent || len(nested.identityFields) != 1 || nested.identityFields[0] != "name" ||
|
||||
!nested.explicitModePresent || nested.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("tier %d changed nested collection policy: %#v, found %t", index, nested, found)
|
||||
}
|
||||
keyOnly, found := assembly.current.collectionPolicies.at(Keys("keyOnly"))
|
||||
if !found || !keyOnly.identityPresent || keyOnly.explicitModePresent {
|
||||
t.Fatalf("tier %d changed key-only policy: %#v, found %t", index, keyOnly, found)
|
||||
}
|
||||
modeOnly, found := assembly.current.collectionPolicies.at(Keys("modeOnly"))
|
||||
if !found || modeOnly.identityPresent || !modeOnly.explicitModePresent || modeOnly.explicitMode != sequenceModeAppend {
|
||||
t.Fatalf("tier %d changed mode-only policy: %#v, found %t", index, modeOnly, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayNonSequenceClaimAtRetainedCollectionStillFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var policies collectionPolicyScope
|
||||
policies.set(Keys("items"), collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
})
|
||||
base := &composedDocument{
|
||||
root: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "items", Value: &document.Node{Kind: document.Sequence},
|
||||
}}},
|
||||
collectionPolicies: policies,
|
||||
origins: make(map[string]Origin),
|
||||
}
|
||||
assembly := newAssemblyAccumulator(base)
|
||||
overlay := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||
Key: "items", Value: &document.Node{Kind: document.String, Text: "not a sequence"},
|
||||
}}}
|
||||
if _, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
overlay, nil, collectionPolicyScope{}, nil, LayerUnderlay, sequencePolicy{}, nil,
|
||||
); len(diagnostics) == 0 {
|
||||
t.Fatal("non-sequence overlay at an append collection unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,18 @@ type sequenceHistoryContribution struct {
|
||||
type assemblyAccumulator struct {
|
||||
current *composedDocument
|
||||
index mappingIndex
|
||||
collectionPolicyRetirement *collectionPolicyRetirementIndex
|
||||
}
|
||||
|
||||
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
||||
return &assemblyAccumulator{current: owned}
|
||||
policies := collectionPolicyScope{}
|
||||
if owned != nil {
|
||||
policies = owned.collectionPolicies
|
||||
}
|
||||
return &assemblyAccumulator{
|
||||
current: owned,
|
||||
collectionPolicyRetirement: newCollectionPolicyRetirementIndex(policies),
|
||||
}
|
||||
}
|
||||
|
||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||
@@ -305,14 +313,10 @@ func resolveStrictCollectionPolicies(
|
||||
valuePresent := make(map[string]bool)
|
||||
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
|
||||
for _, claim := range claims {
|
||||
if len(lower.collections) != 0 {
|
||||
valuePresent[claim.path.String()] = true
|
||||
}
|
||||
if claim.kind != document.Sequence {
|
||||
continue
|
||||
}
|
||||
key := claim.path.String()
|
||||
if !seen[key] {
|
||||
valuePresent[key] = true
|
||||
_, hasLower := lower.collections[key]
|
||||
if (claim.kind == document.Sequence || hasLower) && !seen[key] {
|
||||
seen[key] = true
|
||||
paths = append(paths, claim.path.clone())
|
||||
}
|
||||
@@ -327,21 +331,18 @@ func resolveStrictCollectionPolicies(
|
||||
}
|
||||
|
||||
identityFieldsByPath := make(map[string][]string)
|
||||
effectiveLower := lower
|
||||
if len(lower.collections) != 0 {
|
||||
effectiveLower, _ = lowerCollectionPoliciesForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||
}
|
||||
collectionPolicies := cloneCollectionPolicyScope(effectiveLower)
|
||||
replacements := collectionReplacementPathsForClaims(lower, claims, declarationsByPath, applicationPolicy)
|
||||
lowerView := lowerCollectionPolicyView{scope: lower, replacements: replacements}
|
||||
var collectionPolicies collectionPolicyScope
|
||||
var diagnostics []Diagnostic
|
||||
for _, path := range paths {
|
||||
key := path.String()
|
||||
currentDeclarations := declarationsByPath[key]
|
||||
|
||||
lowerEntry, hasLower := effectiveLower.collections[key]
|
||||
lowerPolicyValue, hasLower := lowerView.at(path)
|
||||
var lowerPolicy *collectionPolicy
|
||||
if hasLower {
|
||||
copy := cloneCollectionPolicy(lowerEntry.policy)
|
||||
lowerPolicy = ©
|
||||
lowerPolicy = &lowerPolicyValue
|
||||
}
|
||||
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
|
||||
|
||||
@@ -358,7 +359,7 @@ func resolveStrictCollectionPolicies(
|
||||
if hasLower && !valuePresent[key] {
|
||||
// A declaration without a new value changes policy metadata but
|
||||
// leaves retained record ownership untouched.
|
||||
policyForPath = cloneCollectionPolicy(lowerEntry.policy)
|
||||
policyForPath = cloneCollectionPolicy(lowerPolicyValue)
|
||||
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
|
||||
policyForPath.identityPresent = resolution.policy.identityPresent
|
||||
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
|
||||
@@ -367,7 +368,7 @@ func resolveStrictCollectionPolicies(
|
||||
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
|
||||
}
|
||||
if collectionPolicies.collections == nil {
|
||||
collectionPolicies.collections = make(map[string]collectionPolicyEntry)
|
||||
collectionPolicies.collections = make(map[string]collectionPolicyEntry, len(paths))
|
||||
}
|
||||
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
|
||||
}
|
||||
@@ -483,21 +484,6 @@ func allMappingClaims(claims []compositionClaim) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
|
||||
for _, incomingEntry := range incoming.Mapping {
|
||||
position := index.find(target, incomingEntry.Key)
|
||||
if position < 0 {
|
||||
cloned := incomingEntry
|
||||
cloned.Value = document.Clone(incomingEntry.Value)
|
||||
index.append(target, cloned)
|
||||
continue
|
||||
}
|
||||
// Claim validation guarantees that the only remaining overlap is a
|
||||
// mapping pair. Empty mappings naturally contribute no child entries.
|
||||
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
|
||||
}
|
||||
}
|
||||
|
||||
func documentEntryIndex(entries []document.Entry, key string) int {
|
||||
for index := range entries {
|
||||
if entries[index].Key == key {
|
||||
|
||||
@@ -489,6 +489,96 @@ func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsReturnsSparseCollectionPolicies(t *testing.T) {
|
||||
itemsPath := Keys("items")
|
||||
unrelatedPath := Keys("unrelated")
|
||||
var lower collectionPolicyScope
|
||||
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
unrelated := collectionPolicy{identityFields: []string{"name"}, identityPresent: true}
|
||||
unrelated.setRecordPolicy(newCollectionRecordPolicy())
|
||||
lower.set(unrelatedPath, unrelated)
|
||||
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, nil,
|
||||
parseCompositionDocument(t, "incoming", "items: [{id: current}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose incoming sequence: %#v", diagnostics)
|
||||
}
|
||||
if len(composed.collectionPolicies.collections) != 1 {
|
||||
t.Fatalf("incoming collection policies = %#v; want only touched destinations", composed.collectionPolicies.collections)
|
||||
}
|
||||
if policy, found := composed.collectionPolicies.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("resolved incoming policy = %#v, found %t", policy, found)
|
||||
}
|
||||
if _, found := composed.collectionPolicies.at(unrelatedPath); found {
|
||||
t.Fatal("incoming composition copied an unrelated lower policy")
|
||||
}
|
||||
if policy, found := lower.at(unrelatedPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"name"}) || len(policy.recordOrder) != 1 {
|
||||
t.Fatalf("borrowed lower policy changed during composition: %#v, found %t", policy, found)
|
||||
}
|
||||
|
||||
composed.collectionPolicies.collections[itemsPath.String()].policy.identityFields[0] = "changed"
|
||||
if policy, _ := lower.at(itemsPath); !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("incoming policy shares identity storage with lower scope: %#v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictCompositionMasksLowerIdentityForReplacedAncestor(t *testing.T) {
|
||||
itemsPath := Keys("catalog", "items")
|
||||
var lower collectionPolicyScope
|
||||
lower.set(itemsPath, collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||
declaration := sequenceDeclarationSpec{destination: Keys("catalog"), mode: sequenceModeReplace, modePresent: true}
|
||||
_, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||
parseCompositionDocument(t, "source-1", "catalog: {items: [{name: first}]}\n"),
|
||||
parseCompositionDocument(t, "source-2", "catalog: {items: [{name: second}]}\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/catalog/items" {
|
||||
t.Fatalf("strict peers used identity masked by replaced ancestor: %#v", diagnostics)
|
||||
}
|
||||
if policy, found := lower.at(itemsPath); !found || !policy.identityPresent || !reflect.DeepEqual(policy.identityFields, []string{"id"}) {
|
||||
t.Fatalf("strict resolution mutated borrowed lower identity: %#v, found %t", policy, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsDeclarationWithoutValueRetainsOwnedRecordPolicy(t *testing.T) {
|
||||
path := Keys("items")
|
||||
nestedPath := Keys("prompts")
|
||||
var nested collectionPolicyScope
|
||||
nested.set(nestedPath, collectionPolicy{identityFields: []string{"name"}, identityPresent: true})
|
||||
record := collectionRecordPolicy{owner: newCollectionRecordPolicy().owner, scope: nested}
|
||||
lowerPolicy := collectionPolicy{
|
||||
identityFields: []string{"id"}, identityPresent: true,
|
||||
explicitMode: sequenceModeAppend, explicitModePresent: true,
|
||||
}
|
||||
lowerPolicy.setRecordPolicy(record)
|
||||
var lower collectionPolicyScope
|
||||
lower.set(path, lowerPolicy)
|
||||
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
|
||||
|
||||
composed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
|
||||
parseCompositionDocument(t, "metadata", "other: value\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose declaration without a value: %#v", diagnostics)
|
||||
}
|
||||
resolved, found := composed.collectionPolicies.at(path)
|
||||
if !found || !resolved.identityPresent || !reflect.DeepEqual(resolved.identityFields, []string{"id"}) || resolved.explicitMode != sequenceModeReplace || !resolved.explicitModePresent {
|
||||
t.Fatalf("declaration-only result changed retained collection policy: %#v, found %t", resolved, found)
|
||||
}
|
||||
if len(resolved.recordOrder) != 1 || len(resolved.recordPolicies) != 1 {
|
||||
t.Fatalf("declaration-only result lost record ownership: %#v", resolved)
|
||||
}
|
||||
owner := resolved.recordOrder[0]
|
||||
resolved.recordPolicies[owner].collections[nestedPath.String()].policy.identityFields[0] = "changed"
|
||||
retained, _ := lower.at(path)
|
||||
if nestedPolicy, found := retained.recordPolicies[owner].at(nestedPath); !found || !reflect.DeepEqual(nestedPolicy.identityFields, []string{"name"}) {
|
||||
t.Fatalf("declaration-only result borrowed nested record policy: %#v, found %t", nestedPolicy, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
|
||||
path := Keys("records")
|
||||
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
|
||||
|
||||
@@ -43,11 +43,12 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
||||
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||
}
|
||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||
if mutateExistingDocumentDescendant(composed.root) {
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("composition result aliases an input document")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base, baseDiagnostics := composeDocuments(left)
|
||||
if len(baseDiagnostics) != 0 {
|
||||
@@ -60,9 +61,41 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay mutated an input document")
|
||||
}
|
||||
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||
if mutateExistingDocumentDescendant(overlaid.root) {
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay result aliases an input document")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mutateExistingDocumentDescendant(node *document.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
// Empty containers have no existing element for the alias probe.
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
if len(node.Mapping) == 0 {
|
||||
return false
|
||||
}
|
||||
entry := &node.Mapping[0]
|
||||
if mutateExistingDocumentDescendant(entry.Value) {
|
||||
return true
|
||||
}
|
||||
entry.Key += "-fuzz-detachment"
|
||||
return true
|
||||
case document.Sequence:
|
||||
if len(node.Sequence) == 0 {
|
||||
return false
|
||||
}
|
||||
if mutateExistingDocumentDescendant(node.Sequence[0]) {
|
||||
return true
|
||||
}
|
||||
node.Sequence[0].Text += "fuzz-detachment"
|
||||
return true
|
||||
default:
|
||||
node.Text += "fuzz-detachment"
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ type decodeIssue struct {
|
||||
}
|
||||
|
||||
type inputPresence struct {
|
||||
null bool
|
||||
location document.Location
|
||||
}
|
||||
|
||||
@@ -91,19 +90,30 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
}
|
||||
}
|
||||
if descriptor.kind == descriptorPointer {
|
||||
var elementBase reflect.Value
|
||||
if base.IsNil() {
|
||||
elementBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||
} else {
|
||||
elementBase = base.Elem()
|
||||
terminal, ok := terminalDescriptor(descriptor)
|
||||
if !ok {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration type cannot represent a non-null value")}
|
||||
}
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||
var pointers []*typeDescriptor
|
||||
currentBase := base
|
||||
for current := descriptor; current != terminal; current = current.element {
|
||||
pointers = append(pointers, current)
|
||||
if currentBase.IsNil() {
|
||||
currentBase = reflect.Zero(current.typeOf.Elem())
|
||||
} else {
|
||||
currentBase = currentBase.Elem()
|
||||
}
|
||||
}
|
||||
decoded, issues := decodeValue(node, currentBase, terminal, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf.Elem())
|
||||
for i := len(pointers) - 1; i >= 0; i-- {
|
||||
result := reflect.New(pointers[i].typeOf.Elem())
|
||||
result.Elem().Set(decoded)
|
||||
return result, nil
|
||||
decoded = result
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
switch descriptor.kind {
|
||||
@@ -392,10 +402,11 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
||||
}
|
||||
|
||||
func acceptsMapping(descriptor *typeDescriptor) bool {
|
||||
for descriptor != nil && descriptor.kind == descriptorPointer {
|
||||
descriptor = descriptor.element
|
||||
terminal, ok := terminalDescriptor(descriptor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic)
|
||||
return terminal.kind == descriptorStruct || terminal.kind == descriptorMap || terminal.kind == descriptorDynamic
|
||||
}
|
||||
|
||||
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
|
||||
@@ -422,7 +433,7 @@ func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Val
|
||||
}
|
||||
|
||||
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
|
||||
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location}
|
||||
presence[path.String()] = inputPresence{location: node.Location}
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
|
||||
@@ -5,6 +5,29 @@ import (
|
||||
"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) {
|
||||
names := []string{"a-base", "b-independent", "z-dependent"}
|
||||
keys := make(map[string]importNodeKey, len(names))
|
||||
@@ -33,7 +56,7 @@ func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.
|
||||
}
|
||||
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))
|
||||
for index, contribution := range scheduled {
|
||||
got[index] = contribution.identity.resourcePath[1:]
|
||||
|
||||
@@ -11,33 +11,13 @@ var (
|
||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||
)
|
||||
|
||||
type placementMode uint8
|
||||
type PlacementMode uint8
|
||||
|
||||
const (
|
||||
placementModeMapping placementMode = iota
|
||||
placementModeSequenceItem
|
||||
PlacementModeMapping PlacementMode = iota
|
||||
PlacementModeSequenceItem
|
||||
)
|
||||
|
||||
// PlacementModeFromFacade preserves root-owned numeric values until preflight
|
||||
// can reject unsupported modes.
|
||||
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
|
||||
|
||||
// PlacementModeForFacade converts private source evidence to the root-owned mode.
|
||||
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
|
||||
|
||||
// InputWithPlacement copies the root-owned mode into a private engine input.
|
||||
func InputWithPlacement(input Input, value uint8) Input {
|
||||
input.placementMode = placementMode(value)
|
||||
return input
|
||||
}
|
||||
|
||||
// OptionsWithPlacement copies root-owned controls into private engine options.
|
||||
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
|
||||
options.destination = destination.clone()
|
||||
options.placementMode = placementMode(value)
|
||||
return options
|
||||
}
|
||||
|
||||
func normalizeDestination(path Path) (Path, error) {
|
||||
if !path.valid() {
|
||||
return Path{}, errInvalidDestination
|
||||
@@ -74,26 +54,26 @@ func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
// placeDocument checks all per-document bounds before allocating destination
|
||||
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||
return placeDocumentWithMode(body, destination, PlacementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
// placeDocumentWithMode places a source mapping beneath its literal destination.
|
||||
// Sequence-item placement adds one sequence containing the complete mapping.
|
||||
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
func placeDocumentWithMode(body *document.Node, destination Path, mode PlacementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
normalized, err := normalizeDestination(destination)
|
||||
if err != nil {
|
||||
return Path{}, nil, err
|
||||
}
|
||||
if mode != placementModeMapping && mode != placementModeSequenceItem {
|
||||
if mode != PlacementModeMapping && mode != PlacementModeSequenceItem {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if mode == placementModeSequenceItem && len(normalized.segments) == 0 {
|
||||
if mode == PlacementModeSequenceItem && len(normalized.segments) == 0 {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if body == nil {
|
||||
return normalized, nil, nil
|
||||
}
|
||||
if mode == placementModeSequenceItem && body.Kind != document.Mapping {
|
||||
if mode == PlacementModeSequenceItem && body.Kind != document.Mapping {
|
||||
return normalized, nil, errInvalidDestination
|
||||
}
|
||||
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
|
||||
@@ -101,7 +81,7 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
|
||||
}
|
||||
|
||||
placed := body
|
||||
if mode == placementModeSequenceItem {
|
||||
if mode == PlacementModeSequenceItem {
|
||||
placed = &document.Node{
|
||||
Kind: document.Sequence, Location: sourceRoot,
|
||||
Sequence: []*document.Node{placed},
|
||||
@@ -125,20 +105,20 @@ func placeDocumentWithMode(body *document.Node, destination Path, mode placement
|
||||
|
||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
|
||||
return destinationWithinLimitsWithMode(body, destination, PlacementModeMapping, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
|
||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode PlacementMode, maxDepth, maxNodes int) bool {
|
||||
if body == nil {
|
||||
return true
|
||||
}
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != PlacementModeMapping && mode != PlacementModeSequenceItem) {
|
||||
return false
|
||||
}
|
||||
segments := len(destination.segments)
|
||||
sequenceNodes := 0
|
||||
sequenceDepth := 0
|
||||
if mode == placementModeSequenceItem {
|
||||
if mode == PlacementModeSequenceItem {
|
||||
if segments == 0 {
|
||||
return false
|
||||
}
|
||||
@@ -160,9 +140,9 @@ func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode
|
||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int {
|
||||
func destinationWrapperNodeCountForMode(destination Path, mode PlacementMode) int {
|
||||
count := destinationWrapperNodeCount(destination)
|
||||
if mode == placementModeSequenceItem {
|
||||
if mode == PlacementModeSequenceItem {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
|
||||
@@ -34,17 +34,17 @@ func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: empty, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("over-budget placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
|
||||
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
|
||||
if acquired == nil || len(diagnostics) != 0 || acquired.inputNodes != test.wantCharge {
|
||||
t.Fatalf("bounded placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -62,9 +62,9 @@ func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
||||
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||
charge := len(inputs) * perInput
|
||||
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||
composition, _, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || acquired == nil || accounting.nodes != limits.nodes || acquired.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", acquired, diagnostics, accounting, charge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,9 @@ id: parent
|
||||
childDestination := appendDestination(destination, Keys("children"))
|
||||
otherDestination := Keys("other")
|
||||
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
||||
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
|
||||
destinationWrapperNodeCountForMode(destination, PlacementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(childDestination, PlacementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(otherDestination, PlacementModeSequenceItem)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
@@ -107,22 +107,22 @@ id: parent
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: destination, placementMode: PlacementModeSequenceItem,
|
||||
}, {
|
||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
|
||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: PlacementModeSequenceItem,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("under-budget nested item glob = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
|
||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||
if acquired == nil || len(diagnostics) != 0 || len(acquired.sources) != 3 {
|
||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", acquired, acquired.sources, diagnostics)
|
||||
}
|
||||
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||
if acquired.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", acquired.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
composition, sources, _, diagnostics := testComposeUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: mainSource, imports: true, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
@@ -216,24 +216,27 @@ func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
|
||||
currentLimits := limits
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||
)
|
||||
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
|
||||
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
|
||||
if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound relative placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", acquired, sources, diagnostics)
|
||||
}
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
if sources[1].Destination.String() != importDestination.String() {
|
||||
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
|
||||
}
|
||||
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose relative placement: %#v", diagnostics)
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty relative import body = %#v, present %v", node, present)
|
||||
}
|
||||
@@ -279,26 +282,29 @@ func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
|
||||
currentLimits := limits
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||
)
|
||||
request := testImportRequest(File(mainPath), nil, main, entryDestination, nil, nil)
|
||||
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, currentLimits, accounting)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
|
||||
if acquired != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound glob placement = %#v, %#v", acquired, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 {
|
||||
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(sources) != len(fragmentPaths)+1 {
|
||||
t.Fatalf("bounded glob placement = %#v, %#v, %#v", acquired, sources, diagnostics)
|
||||
}
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("glob relative placement node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
for index, source := range sources[1:] {
|
||||
if source.Destination.String() != importDestination.String() {
|
||||
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
|
||||
}
|
||||
}
|
||||
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: currentLimits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose glob placement: %#v", diagnostics)
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
|
||||
}
|
||||
@@ -366,15 +372,18 @@ func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
||||
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
request := testImportRequest(File(mainPath), nil, main, destination, nil, nil)
|
||||
acquired, sources, diagnostics := testAcquireImportWithAccounting(request, limits, accounting)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(sources) != 1 {
|
||||
t.Fatalf("absent optional import = %#v, %#v, %#v", acquired, sources, diagnostics)
|
||||
}
|
||||
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
if acquired.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("absent optional node charge = %d/%d, want %d", acquired.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
composed, diagnostics := testComposeAcquiredImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, acquired, sources)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose absent optional import: %#v", diagnostics)
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty entry body = %#v, present %v", node, present)
|
||||
@@ -387,12 +396,12 @@ func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounting := &importAccounting{}
|
||||
composition, sources, _, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
acquired, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
{source: NoSource(), destination: Keys("absent")},
|
||||
{source: Mapping("root", map[string]any{})},
|
||||
}}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
|
||||
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(acquired.sources) != 1 || accounting.nodes != 1 || acquired.inputNodes != 1 {
|
||||
t.Fatalf("root and absent placement = %#v, diagnostics %#v, accounting %#v", acquired, diagnostics, accounting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +421,7 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
|
||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
_, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||
t.Fatalf("overlay diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -431,7 +440,7 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||
overrides: []Override{override},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
||||
input, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
input, diagnostics := testAssembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("assembly diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -443,3 +452,15 @@ func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testAssembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
policy := acquisitionPolicy{
|
||||
importDefaults: request.importDefaults,
|
||||
sequencePolicy: request.sequencePolicy,
|
||||
importLimits: defaultImportLimits(),
|
||||
}
|
||||
return assembleLayeredInput(request, input, policy, &acquisitionState{accounting: accounting, versionState: input.versionState})
|
||||
}
|
||||
|
||||
@@ -108,10 +108,14 @@ parent: true
|
||||
}
|
||||
defer func() { _ = access.close() }()
|
||||
state := importTraversal{
|
||||
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
|
||||
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
|
||||
access: access,
|
||||
policy: &acquisitionPolicy{importLimits: defaultImportLimits()},
|
||||
state: &acquisitionState{accounting: &importAccounting{sources: 1}},
|
||||
tier: &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)},
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -125,11 +129,11 @@ parent: true
|
||||
{path: alphaPath, destination: "/entry/collection"},
|
||||
{path: betaPath, destination: "/entry/collection"},
|
||||
}
|
||||
if len(state.sources) != len(wantSources) {
|
||||
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
|
||||
if len(state.sources) != len(wantSources)+1 {
|
||||
t.Fatalf("sources = %#v, want %d including main", state.sources, len(wantSources)+1)
|
||||
}
|
||||
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 {
|
||||
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
||||
}
|
||||
@@ -160,11 +164,10 @@ func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
||||
t.Run("import context failure", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||
input, diagnostics := assembleBaseInputTracked(
|
||||
File(mainPath),
|
||||
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
|
||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||
)
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: t.TempDir()}, destination: Keys("mounted")},
|
||||
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("context diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -177,10 +180,10 @@ func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||
writeImportFile(t, directory, "broken.yml", "broken: [\n")
|
||||
input, diagnostics := assembleBaseInputTracked(
|
||||
File(mainPath), Options[map[string]any]{Imports: true},
|
||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||
)
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true, destination: Keys("mounted")},
|
||||
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
||||
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
|
||||
}
|
||||
item := func(label string, value map[string]any) Input {
|
||||
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}
|
||||
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem}
|
||||
}
|
||||
options := func(inputs ...Input) LayeredOptions[map[string]any] {
|
||||
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
|
||||
|
||||
@@ -22,11 +22,11 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
|
||||
placementMode: placementModeSequenceItem,
|
||||
PlacementMode: PlacementModeSequenceItem,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -40,7 +40,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved {
|
||||
if len(sources) != 1 || sources[0].placementMode != PlacementModeSequenceItem || sources[0].destinationUnresolved {
|
||||
t.Fatalf("source placement evidence = %#v", sources)
|
||||
}
|
||||
if got := sources[0].Destination.String(); got != "/catalog/entries" {
|
||||
@@ -48,7 +48,7 @@ func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
}
|
||||
|
||||
absent, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -64,17 +64,17 @@ func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode placementMode
|
||||
mode PlacementMode
|
||||
path Path
|
||||
}{
|
||||
{name: "root item", mode: placementModeSequenceItem},
|
||||
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")},
|
||||
{name: "root item", mode: PlacementModeSequenceItem},
|
||||
{name: "unknown mode", mode: PlacementMode(255), path: Keys("catalog")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reader := bytes.NewBufferString("value: unused\n")
|
||||
_, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode},
|
||||
Input{Source: Reader("reader", reader), Destination: test.path, PlacementMode: test.mode},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err == nil || reader.Len() == 0 {
|
||||
@@ -93,7 +93,7 @@ func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
|
||||
|
||||
loaded, err := Load[destinationItemConfig](
|
||||
Mapping("ordinary", map[string]any{}), testSecretBinding(),
|
||||
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
Options[destinationItemConfig]{Destination: Keys("catalog", "entries"), PlacementMode: PlacementModeSequenceItem},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
|
||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, PlacementModeMapping, body.Location, 5, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
root := document.Location{SourceID: "item", Line: 1, Column: 1}
|
||||
body := &document.Node{Kind: document.Mapping, Location: root}
|
||||
destination := Keys("catalog", "entries")
|
||||
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6)
|
||||
placedPath, placed, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, 3, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -253,13 +253,13 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
|
||||
}
|
||||
|
||||
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
if _, _, err := placeDocumentWithMode(body, Path{}, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("root item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, PlacementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
if _, _, err := placeDocumentWithMode(body, destination, PlacementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes)
|
||||
_, _, err := placeDocumentWithMode(body, destination, PlacementModeSequenceItem, root, test.depth, test.nodes)
|
||||
if test.wantAccept && err != nil {
|
||||
t.Fatalf("exact bounds rejected: %v", err)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ type rootDirectiveExtraction struct {
|
||||
body *document.Node
|
||||
imports []importDeclarationSpec
|
||||
sequences []sequenceDeclarationSpec
|
||||
importsPresent bool
|
||||
sequencesPresent bool
|
||||
}
|
||||
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclarationSpec, []Diagnostic) {
|
||||
@@ -74,7 +72,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
|
||||
fieldPath := directivePath.Key(entry.Key)
|
||||
switch entry.Key {
|
||||
case "imports":
|
||||
extracted.importsPresent = true
|
||||
if context == rootDirectiveContextOverlay {
|
||||
diagnostics = append(diagnostics, nodeImportDiagnostic(CodeOverlayDirective, fieldPath, "overlays cannot contain imports", entry.Value))
|
||||
continue
|
||||
@@ -87,7 +84,6 @@ func parseRootDirectiveBlock(node *document.Node, directivePath Path, context ro
|
||||
extracted.imports = append(extracted.imports, parsed...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
case "sequences":
|
||||
extracted.sequencesPresent = true
|
||||
if context == rootDirectiveContextImportsOnly {
|
||||
diagnostics = append(diagnostics, sequenceDeclarationDiagnostic(fieldPath, "sequence declarations are not enabled in this extraction context", entry.Value))
|
||||
continue
|
||||
|
||||
@@ -255,7 +255,7 @@ func selectDirectoryWithDiagnostics(t *testing.T, access *filesystemAccess, base
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestSelectShallowDirectoriesUnionsRootsAndPatterns(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if selection.outcome != importSelectionHasCandidates || len(selection.candidates) != 3 {
|
||||
if len(selection.candidates) != 3 {
|
||||
t.Fatalf("selection = %#v, want three unioned matches", selection)
|
||||
}
|
||||
if selection.candidates[0].path != "one/README" || selection.candidates[1].path != "one/a.yaml" || selection.candidates[2].path != "two/a.yaml" {
|
||||
@@ -82,7 +82,7 @@ func TestSelectShallowDirectoriesIncludeUnrestrictedHiddenAndExtensionlessFiles(
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func TestSelectShallowDirectoriesApplyRequirednessToFinalUnion(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestSelectShallowDirectoryRejectsInvalidRootInsteadOfTreatingItAsMissing(t
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics = resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics = resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestSelectShallowDirectoryFiltersSymlinkAliasSpelling(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
yaml string
|
||||
defaults importDefaults
|
||||
defaults ImportDefaults
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
@@ -280,13 +280,13 @@ func TestSelectDirectoriesApplyRecursiveDepthToFileLevels(t *testing.T) {
|
||||
{
|
||||
name: "explicit false overrides application recursion",
|
||||
yaml: " - dir: .\n recursive: false\n pattern: '*.yaml'\n",
|
||||
defaults: importDefaults{recursive: boolPointer(true)},
|
||||
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||
wantPaths: []string{"direct.yaml"},
|
||||
},
|
||||
{
|
||||
name: "application default enables recursion for depth",
|
||||
yaml: " - dir: .\n depth: 2\n pattern: '*.yaml'\n",
|
||||
defaults: importDefaults{recursive: boolPointer(true)},
|
||||
defaults: ImportDefaults{Recursive: boolPointer(true)},
|
||||
wantPaths: []string{"child.yaml", "direct.yaml"},
|
||||
},
|
||||
{
|
||||
@@ -347,13 +347,13 @@ func TestSelectDirectoriesRetainsEmptySearchAndDirectoryVisitLimits(t *testing.T
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
accounting := &importAccounting{}
|
||||
selection, diagnostics := selectDirectoryImportCandidates(access, root, specs[0], policy, defaultImportLimits(), accounting)
|
||||
if len(diagnostics) != 0 || selection.outcome != importSelectionNone || len(selection.candidates) != 0 {
|
||||
if len(diagnostics) != 0 || len(selection.candidates) != 0 {
|
||||
t.Fatalf("empty recursive selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if accounting.entries != 5 {
|
||||
|
||||
@@ -28,7 +28,7 @@ settings: {main: selected}
|
||||
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
|
||||
search := SearchConfig("app", SearchDirectory(root))
|
||||
options := Options[discoveryLoadConfig]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"),
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root}, Destination: Keys("service"),
|
||||
}
|
||||
searched, err := testLoad(search, options)
|
||||
if err != nil {
|
||||
@@ -172,7 +172,7 @@ func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
|
||||
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||
}{
|
||||
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{destination: Keys("settings")})
|
||||
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Destination: Keys("settings")})
|
||||
}},
|
||||
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||
|
||||
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
160
internal/engine/dynamic_descriptor_transaction_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type unsupportedDynamicRoot struct {
|
||||
Child unsupportedDynamicChild `yaml:"child"`
|
||||
Invalid chan int `yaml:"invalid"`
|
||||
}
|
||||
|
||||
type unsupportedDynamicChild struct {
|
||||
Parent *unsupportedDynamicRoot `yaml:"parent"`
|
||||
}
|
||||
|
||||
type secretDynamicRoot struct {
|
||||
Nested secretDynamicNested `yaml:"nested"`
|
||||
}
|
||||
|
||||
type secretDynamicNested struct {
|
||||
Token Secret `yaml:"token"`
|
||||
}
|
||||
|
||||
type dynamicTransactionConfig struct {
|
||||
First any `yaml:"first"`
|
||||
Second any `yaml:"second"`
|
||||
Good any `yaml:"good"`
|
||||
}
|
||||
|
||||
func TestDynamicDescriptorFailuresRollBackProvisionalTypes(t *testing.T) {
|
||||
called := false
|
||||
result, err := testLoad[dynamicTransactionConfig](NoSource(), Options[dynamicTransactionConfig]{Definition: Definition[dynamicTransactionConfig]{
|
||||
Resolve: func(config *dynamicTransactionConfig, resolution *Resolution) error {
|
||||
called = true
|
||||
Normalize(resolution, Keys("first"), &config.First, any((*unsupportedDynamicRoot)(nil)), "try typed nil")
|
||||
Normalize(resolution, Keys("second"), &config.Second, any(&unsupportedDynamicRoot{}), "try non-nil")
|
||||
Normalize(resolution, Keys("good"), &config.Good, any(map[string]string{"valid": "value"}), "continue with supported value")
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
var loadErr *LoadError
|
||||
if !called || result != nil || !errors.As(err, &loadErr) {
|
||||
t.Fatalf("result = %#v, called=%v, error=%v", result, called, err)
|
||||
}
|
||||
paths := make(map[string]Code)
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
paths[diagnostic.Path.String()] = diagnostic.Code
|
||||
}
|
||||
for _, path := range []string{"/first", "/second"} {
|
||||
if paths[path] != CodeResolveInvalidValue {
|
||||
t.Errorf("diagnostic at %s = %q, want safe invalid-value failure", path, paths[path])
|
||||
}
|
||||
}
|
||||
|
||||
schema := mustSchemaFor[dynamicTransactionConfig](t)
|
||||
operation := valueOperation{schema: schema}
|
||||
prior, issue := operation.dynamicDescriptor(reflect.TypeFor[map[string]string](), Keys("prior"))
|
||||
if issue != nil {
|
||||
t.Fatalf("prior valid dynamic descriptor issue = %#v", issue)
|
||||
}
|
||||
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("failure")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||
t.Fatalf("unsupported dynamic descriptor issue = %#v", issue)
|
||||
}
|
||||
if _, approved := operation.approvedDynamic[reflect.TypeFor[unsupportedDynamicRoot]()]; approved {
|
||||
t.Fatal("unsupported dynamic descriptor was cached as eligible")
|
||||
}
|
||||
if schema.types[reflect.TypeFor[map[string]string]()] != prior {
|
||||
t.Fatal("failed build removed a previously completed descriptor")
|
||||
}
|
||||
for _, current := range []reflect.Type{
|
||||
reflect.TypeFor[unsupportedDynamicRoot](),
|
||||
reflect.TypeFor[unsupportedDynamicChild](),
|
||||
reflect.TypeFor[*unsupportedDynamicRoot](),
|
||||
} {
|
||||
if _, exists := schema.types[current]; exists {
|
||||
t.Errorf("failed build retained provisional descriptor for %v", current)
|
||||
}
|
||||
}
|
||||
if _, issue := operation.dynamicDescriptor(reflect.TypeFor[unsupportedDynamicRoot](), Keys("again")); issue == nil || issue.path.String() != "/again" {
|
||||
t.Fatalf("repeated failure issue = %#v", issue)
|
||||
}
|
||||
|
||||
secretSchema, schemaIssue := buildSchema(reflect.TypeFor[dynamicTransactionConfig](), testSecretBinding())
|
||||
if schemaIssue != nil {
|
||||
t.Fatalf("secret schema issue = %#v", schemaIssue)
|
||||
}
|
||||
secretOperation := valueOperation{schema: secretSchema}
|
||||
if _, issue := secretOperation.dynamicDescriptor(reflect.TypeFor[secretDynamicRoot](), Keys("secret")); issue == nil || issue.code != CodeSchemaDynamicType {
|
||||
t.Fatalf("dynamic secret eligibility issue = %#v", issue)
|
||||
}
|
||||
if len(secretOperation.approvedDynamic) != 0 {
|
||||
t.Fatalf("secret-bearing dynamic descriptor was cached as eligible: %#v", secretOperation.approvedDynamic)
|
||||
}
|
||||
for _, current := range []reflect.Type{reflect.TypeFor[secretDynamicRoot](), reflect.TypeFor[secretDynamicNested](), reflect.TypeFor[Secret]()} {
|
||||
if _, exists := secretSchema.types[current]; exists {
|
||||
t.Errorf("secret eligibility failure retained provisional descriptor for %v", current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type wideDynamicEligibilityValue struct {
|
||||
Alpha string
|
||||
Bravo string
|
||||
Charlie string
|
||||
Delta string
|
||||
Echo string
|
||||
Foxtrot string
|
||||
Golf string
|
||||
Hotel string
|
||||
India string
|
||||
Juliet string
|
||||
Kilo string
|
||||
Lima string
|
||||
Mike string
|
||||
November string
|
||||
Oscar string
|
||||
Papa string
|
||||
}
|
||||
|
||||
type repeatedNilDynamicEligibilityConfig struct {
|
||||
Values []any
|
||||
}
|
||||
|
||||
func TestDynamicEligibilityIsReusedWithinProjectionOperation(t *testing.T) {
|
||||
schema, schemaIssue := buildSchema(reflect.TypeFor[repeatedNilDynamicEligibilityConfig](), testSecretBinding())
|
||||
if schemaIssue != nil {
|
||||
t.Fatalf("schema issue = %#v", schemaIssue)
|
||||
}
|
||||
values := make([]any, 32)
|
||||
for index := range values {
|
||||
values[index] = (*wideDynamicEligibilityValue)(nil)
|
||||
}
|
||||
configuration := repeatedNilDynamicEligibilityConfig{Values: values}
|
||||
operation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||
node, issue := operation.project(reflect.ValueOf(configuration), schema.root, Path{}, 0)
|
||||
if issue != nil {
|
||||
t.Fatalf("repeated typed-nil projection issue = %#v", issue)
|
||||
}
|
||||
if len(node.Mapping) != 1 || len(node.Mapping[0].Value.Sequence) != len(values) {
|
||||
t.Fatalf("projected repeated values = %#v", node)
|
||||
}
|
||||
for index, child := range node.Mapping[0].Value.Sequence {
|
||||
if child.Kind != document.Null {
|
||||
t.Errorf("projected value %d kind = %v, want null", index, child.Kind)
|
||||
}
|
||||
}
|
||||
current := reflect.TypeFor[*wideDynamicEligibilityValue]()
|
||||
if len(operation.approvedDynamic) != 1 || operation.approvedDynamic[current] == nil ||
|
||||
operation.approvedDynamic[current] != schema.types[current] {
|
||||
t.Fatalf("operation eligibility cache = %#v", operation.approvedDynamic)
|
||||
}
|
||||
otherOperation := valueOperation{schema: schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool)}
|
||||
if len(otherOperation.approvedDynamic) != 0 {
|
||||
t.Fatalf("dynamic eligibility leaked into another operation: %#v", otherOperation.approvedDynamic)
|
||||
}
|
||||
}
|
||||
@@ -308,12 +308,18 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
|
||||
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
|
||||
leaf := &document.Node{Kind: document.String, Text: "value"}
|
||||
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
|
||||
applications := []historyApplication{{layer: LayerBase, value: root}}
|
||||
if !historyNodesWithin(2, root, root, root, applications, nil) {
|
||||
inspection := &Inspection{
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,75 +3,37 @@ package engine
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||
if source.kind == sourceNone && context.FS == nil {
|
||||
return validateAbsentHostFilesystemContext(context)
|
||||
}
|
||||
if nativeContext(source, context) {
|
||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||
resolved, diagnostic := resolveFilesystemContext(source, context, main, hostToken)
|
||||
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 validateNativeDirectories(access, basePath, mainPath)
|
||||
return resolvedFilesystemContext{}, diagnostic
|
||||
}
|
||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
return diagnostic
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
return resolvedFilesystemContext{}, diagnostic
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if resolved.native != nil {
|
||||
return resolved, nil
|
||||
}
|
||||
root, err := os.OpenRoot(resolved.rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
value.Primary = nil
|
||||
return &value
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||
return &value
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
return 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
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||
@@ -134,113 +96,6 @@ func nativeContext(source Source, context *FilesystemContext) bool {
|
||||
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
||||
}
|
||||
|
||||
// nativeFilesystemAccess resolves namespace context without opening caller
|
||||
// resources. File inputs always derive their base from the selected file.
|
||||
func nativeFilesystemAccess(source Source, context *FilesystemContext) (*filesystemAccess, string, string, *Diagnostic) {
|
||||
var namespace *nativeNamespace
|
||||
rootPath := "."
|
||||
basePath := "."
|
||||
mainPath := ""
|
||||
if source.kind == sourceFilesystem {
|
||||
namespace = source.namespace
|
||||
mainPath = source.path
|
||||
basePath = path.Dir(source.path)
|
||||
if context != nil && context.Root != "" {
|
||||
rootPath = context.Root
|
||||
}
|
||||
} else if context != nil && context.FS != nil {
|
||||
namespace = context.FS.namespace
|
||||
rootPath, basePath = context.Root, context.BaseDir
|
||||
}
|
||||
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(rootPath) || !fs.ValidPath(basePath) {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
|
||||
return nil, "", "", &value
|
||||
}
|
||||
access := newNativeAccess(namespace.filesystem, rootPath, namespace.token)
|
||||
access.label = namespace.label
|
||||
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return nil, "", "", &value
|
||||
}
|
||||
return access, basePath, mainPath, nil
|
||||
}
|
||||
|
||||
func validateNativeDirectories(access *filesystemAccess, basePath, mainPath string) *Diagnostic {
|
||||
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return &value
|
||||
}
|
||||
for _, directory := range []string{access.rootPath, basePath} {
|
||||
info, err := access.stat(directory)
|
||||
if err != nil || !info.IsDir() {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||
return &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
||||
if main.source == nil || main.document == nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
|
||||
if source.kind == sourceFile {
|
||||
if context != nil && context.BaseDir != "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
||||
return "", "", "", &value
|
||||
}
|
||||
canonical, err := canonicalExistingPath(source.path)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
mainPath = canonical
|
||||
basePath = filepath.Dir(canonical)
|
||||
rootPath = basePath
|
||||
if context != nil && context.Root != "" {
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if context == nil || context.BaseDir == "" || context.Root == "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
||||
return "", "", "", &value
|
||||
}
|
||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
||||
return "", "", "", &value
|
||||
}
|
||||
var err error
|
||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and 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
|
||||
}
|
||||
}
|
||||
return rootPath, basePath, mainPath, nil
|
||||
}
|
||||
|
||||
func canonicalExistingPath(name string) (string, error) {
|
||||
absolute, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
|
||||
@@ -141,40 +141,59 @@ func (a *filesystemAccess) open(name string) (fs.File, error) {
|
||||
return a.root.Open(relative)
|
||||
}
|
||||
|
||||
type directoryReadError struct {
|
||||
opened bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *directoryReadError) Error() string { return "directory could not be read" }
|
||||
func (e *directoryReadError) Unwrap() error { return e.err }
|
||||
|
||||
func missingDirectoryOpen(err error) bool {
|
||||
var failure *directoryReadError
|
||||
return errors.As(err, &failure) && !failure.opened && errors.Is(err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
|
||||
file, err := a.open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
return &directoryReadError{err: err}
|
||||
}
|
||||
failure := func(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &directoryReadError{opened: true, err: err}
|
||||
}
|
||||
if reader, ok := file.(fs.ReadDirFile); ok {
|
||||
for {
|
||||
entries, readErr := reader.ReadDir(globReadBatch)
|
||||
for _, entry := range entries {
|
||||
if !visit(entry) {
|
||||
return file.Close()
|
||||
return failure(file.Close())
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
return file.Close()
|
||||
return failure(file.Close())
|
||||
}
|
||||
if readErr != nil {
|
||||
_ = file.Close()
|
||||
return readErr
|
||||
return failure(readErr)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
_ = file.Close()
|
||||
return io.ErrNoProgress
|
||||
return failure(io.ErrNoProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
return failure(err)
|
||||
}
|
||||
// fs.ReadDir provides the standard fallback for implementations whose opened
|
||||
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
|
||||
entries, err := fs.ReadDir(a.native, name)
|
||||
if err != nil {
|
||||
return err
|
||||
return failure(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !visit(entry) {
|
||||
|
||||
176
internal/engine/filesystem_context.go
Normal file
176
internal/engine/filesystem_context.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type resolvedFilesystemContext struct {
|
||||
native *nativeNamespace
|
||||
hostToken *namespaceToken
|
||||
rootPath string
|
||||
basePath string
|
||||
mainPath string
|
||||
}
|
||||
|
||||
func resolveFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||
if source.kind == sourceNone && context != nil {
|
||||
return resolveAbsentFilesystemContext(context)
|
||||
}
|
||||
if nativeContext(source, context) {
|
||||
return resolveNativeFilesystemContext(source, context)
|
||||
}
|
||||
return resolveHostFilesystemContext(source, context, main, hostToken)
|
||||
}
|
||||
|
||||
func resolveAbsentFilesystemContext(context *FilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||
if context.FS != nil {
|
||||
return resolveNativeFilesystemContext(Source{kind: sourceNone}, context)
|
||||
}
|
||||
rootPath, err := canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return resolvedFilesystemContext{}, &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 resolvedFilesystemContext{}, &value
|
||||
}
|
||||
}
|
||||
if !containedPath(rootPath, basePath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
return resolvedFilesystemContext{rootPath: rootPath, basePath: basePath}, nil
|
||||
}
|
||||
|
||||
func resolveNativeFilesystemContext(source Source, context *FilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||
var namespace *nativeNamespace
|
||||
resolved := resolvedFilesystemContext{rootPath: ".", basePath: "."}
|
||||
if source.kind == sourceFilesystem {
|
||||
namespace = source.namespace
|
||||
resolved.mainPath = source.path
|
||||
resolved.basePath = path.Dir(source.path)
|
||||
if context != nil && context.Root != "" {
|
||||
resolved.rootPath = context.Root
|
||||
}
|
||||
} else if context != nil && context.FS != nil {
|
||||
namespace = context.FS.namespace
|
||||
resolved.rootPath, resolved.basePath = context.Root, context.BaseDir
|
||||
}
|
||||
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(resolved.rootPath) || !fs.ValidPath(resolved.basePath) {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
resolved.native = namespace
|
||||
if !containsNativePath(resolved.rootPath, resolved.basePath) || resolved.mainPath != "" && !containsNativePath(resolved.rootPath, resolved.mainPath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveHostFilesystemContext(source Source, context *FilesystemContext, main parsedSource, hostToken *namespaceToken) (resolvedFilesystemContext, *Diagnostic) {
|
||||
if main.source == nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
resolved := resolvedFilesystemContext{hostToken: hostToken}
|
||||
if source.kind == sourceFile {
|
||||
if context != nil && context.BaseDir != "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
canonical := main.source.Path
|
||||
if canonical == "" {
|
||||
var err error
|
||||
canonical, err = canonicalExistingPath(source.path)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
}
|
||||
resolved.mainPath = canonical
|
||||
resolved.basePath = filepath.Dir(canonical)
|
||||
resolved.rootPath = resolved.basePath
|
||||
if context != nil && context.Root != "" {
|
||||
rootPath, err := canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
resolved.rootPath = rootPath
|
||||
}
|
||||
} else {
|
||||
if context == nil || context.BaseDir == "" || context.Root == "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
var err error
|
||||
resolved.basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
resolved.rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
}
|
||||
if !containedPath(resolved.rootPath, resolved.basePath) || resolved.mainPath != "" && !containedPath(resolved.rootPath, resolved.mainPath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func containsNativePath(root, name string) bool {
|
||||
if !fs.ValidPath(name) || !fs.ValidPath(root) {
|
||||
return false
|
||||
}
|
||||
return root == "." || name == root || strings.HasPrefix(name, root+"/")
|
||||
}
|
||||
|
||||
func validateResolvedFilesystemDirectories(resolved resolvedFilesystemContext) *Diagnostic {
|
||||
for _, directory := range []string{resolved.rootPath, resolved.basePath} {
|
||||
var info fs.FileInfo
|
||||
var err error
|
||||
if resolved.native != nil {
|
||||
info, err = fs.Stat(resolved.native.filesystem, directory)
|
||||
} else {
|
||||
info, err = os.Stat(directory)
|
||||
}
|
||||
if err != nil || !info.IsDir() {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||
return &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openResolvedFilesystemAccess(resolved resolvedFilesystemContext) (*filesystemAccess, error) {
|
||||
if resolved.native != nil {
|
||||
access := newNativeAccess(resolved.native.filesystem, resolved.rootPath, resolved.native.token)
|
||||
access.label = resolved.native.label
|
||||
return access, nil
|
||||
}
|
||||
access, err := openHostAccess(resolved.rootPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved.hostToken != nil {
|
||||
access.token = resolved.hostToken
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
@@ -119,21 +119,34 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
|
||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
||||
}
|
||||
|
||||
// A link stays active until its target has resolved, not until the entire
|
||||
// original pathname has resolved. Remaining segments may reuse a finished link.
|
||||
// A link remains active until its own target resolves. Completed targets can be
|
||||
// reused while the caller continues resolving its remaining components.
|
||||
type importLinkFrame struct {
|
||||
pending []string
|
||||
link string
|
||||
}
|
||||
|
||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}}
|
||||
type importLinkOperations struct {
|
||||
lstat func(string) (fs.FileInfo, error)
|
||||
readlink func(string) (string, error)
|
||||
normalizeTarget func(string, string) (string, error)
|
||||
}
|
||||
|
||||
func resolveImportLinkPath(name string, operations importLinkOperations) (string, error) {
|
||||
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
active := make(map[string]struct{})
|
||||
completed := make(map[string]string)
|
||||
for len(frames) > 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if len(frame.pending) == 0 {
|
||||
delete(activeLinks, frame.link)
|
||||
if frame.link != "" {
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
completed[frame.link] = resolved
|
||||
delete(active, frame.link)
|
||||
}
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
@@ -143,7 +156,7 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
info, err := access.root.Lstat(filepath.FromSlash(candidate))
|
||||
info, err := operations.lstat(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -151,14 +164,44 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
||||
resolved = candidate
|
||||
continue
|
||||
}
|
||||
if _, exists := activeLinks[candidate]; exists {
|
||||
if target, ok := completed[candidate]; ok {
|
||||
resolved = target
|
||||
continue
|
||||
}
|
||||
if _, exists := active[candidate]; exists {
|
||||
return "", errImportDirectoryLinkCycle
|
||||
}
|
||||
activeLinks[candidate] = struct{}{}
|
||||
target, err := access.root.Readlink(filepath.FromSlash(candidate))
|
||||
target, err := operations.readlink(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetPath, err := operations.normalizeTarget(candidate, target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
active[candidate] = struct{}{}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
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))
|
||||
@@ -172,13 +215,9 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
||||
if !withinPortableRoot(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
return targetPath, nil
|
||||
},
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func withinPortableRoot(name string) bool {
|
||||
@@ -189,43 +228,17 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
|
||||
if !fs.ValidPath(name) || !access.contains(name) {
|
||||
return "", fs.ErrInvalid
|
||||
}
|
||||
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||
resolved := ""
|
||||
activeLinks := make(map[string]struct{})
|
||||
for len(frames) > 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if len(frame.pending) == 0 {
|
||||
delete(activeLinks, frame.link)
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
segment := frame.pending[0]
|
||||
frame.pending = frame.pending[1:]
|
||||
candidate := path.Join(resolved, segment)
|
||||
if candidate == "" {
|
||||
candidate = "."
|
||||
}
|
||||
return resolveImportLinkPath(name, importLinkOperations{
|
||||
lstat: func(candidate string) (fs.FileInfo, error) {
|
||||
// Ancestors of a multi-component root are resolution prefixes, not
|
||||
// selected targets. Complete paths and link targets remain contained.
|
||||
if !access.contains(candidate) && !strings.HasPrefix(access.rootPath, candidate+"/") {
|
||||
return "", 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
|
||||
return nil, errImportPathOutsideRoot
|
||||
}
|
||||
return filesystem.Lstat(candidate)
|
||||
},
|
||||
readlink: filesystem.ReadLink,
|
||||
normalizeTarget: func(candidate, target string) (string, error) {
|
||||
if target == "" || strings.HasPrefix(target, "/") {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
@@ -233,13 +246,9 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
|
||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||
return "", errImportPathOutsideRoot
|
||||
}
|
||||
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||
resolved = ""
|
||||
}
|
||||
if resolved == "" {
|
||||
resolved = "."
|
||||
}
|
||||
return resolved, nil
|
||||
return targetPath, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func isImportLinkCycle(err error) bool {
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -45,3 +46,270 @@ func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
||||
// The small graph stays within host symlink-depth limits while exceeding the
|
||||
// bounded linear operation allowance without completed-target reuse.
|
||||
const linkCount = 5
|
||||
root := t.TempDir()
|
||||
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||
for _, directory := range []string{
|
||||
filepath.Join(configurationRoot, "graph"),
|
||||
filepath.Join(configurationRoot, "selected"),
|
||||
} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for index := 0; index < linkCount; index++ {
|
||||
name := fmt.Sprintf("s%02d", index)
|
||||
target := "."
|
||||
if index > 0 {
|
||||
previous := fmt.Sprintf("s%02d", index-1)
|
||||
target = previous + "/" + previous
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(configurationRoot, "graph", name)); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configurationRoot, "graph", "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(fmt.Sprintf("../graph/s%02d", linkCount-1), filepath.Join(configurationRoot, "selected", "entry")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
for _, selector := range []string{"directory", "glob"} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
base := os.DirFS(root)
|
||||
if _, ok := base.(fs.ReadLinkFS); !ok {
|
||||
t.Skip("host directory filesystem does not expose link inspection")
|
||||
}
|
||||
filesystem := newCountingReadLinkFS(base, linkCount*20+40)
|
||||
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
||||
if _, err := access.canonical("workspace/config/selected"); err != nil {
|
||||
t.Fatalf("selected directory canonicalization failed before selection: %v", err)
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
if selector == "directory" {
|
||||
_, diagnostics = selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: true\n")
|
||||
} else {
|
||||
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, "workspace/config", " - glob: 'selected/*/*.yml'\n follow: true\n")
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("selection diagnostics = %#v after %d link operations", diagnostics, filesystem.operations)
|
||||
}
|
||||
if filesystem.operations == 0 || filesystem.operations > filesystem.limit {
|
||||
t.Fatalf("link-resolution operations = %d, want 1..%d", filesystem.operations, filesystem.limit)
|
||||
}
|
||||
if filesystem.readLinks < linkCount {
|
||||
t.Fatalf("link reads = %d, want the selector to resolve the repeated dependency graph", filesystem.readLinks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeLinkResolutionRejectsEscapesAndSkipsWhenFollowingIsDisabled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||
selected := filepath.Join(configurationRoot, "selected")
|
||||
if err := os.MkdirAll(selected, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../../../outside", filepath.Join(selected, "escape")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
base := os.DirFS(root)
|
||||
if _, ok := base.(fs.ReadLinkFS); !ok {
|
||||
t.Skip("host directory filesystem does not expose link inspection")
|
||||
}
|
||||
filesystem := newCountingReadLinkFS(base, 100)
|
||||
access := newNativeAccess(filesystem, "workspace/config", &namespaceToken{})
|
||||
if _, err := resolveNativeImportPath(access, filesystem.links, "workspace/config/selected/escape"); !errors.Is(err, errImportPathOutsideRoot) {
|
||||
t.Fatalf("escaping native link error = %v, want outside-root rejection", err)
|
||||
}
|
||||
|
||||
filesystem.readLinks = 0
|
||||
_, diagnostics := selectDirectoryWithDiagnostics(t, access, "workspace/config", " - dir: selected\n follow: false\n")
|
||||
if len(diagnostics) != 0 || filesystem.readLinks != 0 {
|
||||
t.Fatalf("follow-disabled selection diagnostics=%#v readlink calls=%d", diagnostics, filesystem.readLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostLinkResolutionMemoizesCompletedDependencies(t *testing.T) {
|
||||
// The small graph stays within host symlink-depth limits while exceeding the
|
||||
// bounded linear operation allowance without completed-target reuse.
|
||||
const linkCount = 5
|
||||
root := t.TempDir()
|
||||
configurationRoot := filepath.Join(root, "workspace", "config")
|
||||
graph := filepath.Join(configurationRoot, "graph")
|
||||
selected := filepath.Join(configurationRoot, "selected")
|
||||
for _, directory := range []string{graph, selected} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for index := 0; index < linkCount; index++ {
|
||||
name := fmt.Sprintf("s%02d", index)
|
||||
target := "."
|
||||
if index > 0 {
|
||||
previous := fmt.Sprintf("s%02d", index-1)
|
||||
target = previous + "/" + previous
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(graph, name)); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(graph, "item.yml"), []byte("value: linked\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliasTarget := fmt.Sprintf("../graph/s%02d", linkCount-1)
|
||||
if err := os.Symlink(aliasTarget, filepath.Join(selected, "entry")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
containedAbsolute := filepath.Join(graph, "s00")
|
||||
if err := os.Symlink(containedAbsolute, filepath.Join(selected, "absolute")); err != nil {
|
||||
t.Skipf("absolute symlinks unavailable: %v", err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(selected, "escape")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
name := filepath.ToSlash(filepath.Join("workspace", "config", "selected", "entry"))
|
||||
operations := hostImportLinkOperations(access)
|
||||
var operationCount int
|
||||
lstat := operations.lstat
|
||||
readlink := operations.readlink
|
||||
operations.lstat = func(candidate string) (fs.FileInfo, error) {
|
||||
operationCount++
|
||||
if operationCount > linkCount*20+40 {
|
||||
return nil, errors.New("host link operation limit exceeded")
|
||||
}
|
||||
return lstat(candidate)
|
||||
}
|
||||
operations.readlink = func(candidate string) (string, error) {
|
||||
operationCount++
|
||||
if operationCount > linkCount*20+40 {
|
||||
return "", errors.New("host link operation limit exceeded")
|
||||
}
|
||||
return readlink(candidate)
|
||||
}
|
||||
resolved, err := resolveImportLinkPath(name, operations)
|
||||
if err != nil || resolved != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
||||
t.Fatalf("host repeated-link resolution = %q, %v", resolved, err)
|
||||
}
|
||||
if operationCount == 0 || operationCount > linkCount*20+40 {
|
||||
t.Fatalf("host link operations = %d, want 1..%d", operationCount, linkCount*20+40)
|
||||
}
|
||||
|
||||
contained, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "absolute"))
|
||||
if err != nil || contained != filepath.ToSlash(filepath.Join("workspace", "config", "graph")) {
|
||||
t.Fatalf("contained absolute link = %q, %v", contained, err)
|
||||
}
|
||||
if _, err := resolveHostImportPath(access, filepath.Join("workspace", "config", "selected", "escape")); !errors.Is(err, errImportPathOutsideRoot) {
|
||||
t.Fatalf("escaping absolute link error = %v, want outside-root rejection", err)
|
||||
}
|
||||
|
||||
for _, selector := range []string{"directory", "glob"} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
var diagnostics []Diagnostic
|
||||
if selector == "directory" {
|
||||
_, diagnostics = selectDirectoryWithDiagnostics(t, access, root, " - dir: workspace/config/selected/entry\n follow: true\n")
|
||||
} else {
|
||||
_, diagnostics = selectGlobForTestWithDiagnostics(t, access, root, " - glob: 'workspace/config/selected/entry/*.yml'\n follow: true\n")
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("host selector diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostLinkResolutionKeepsNamespacesIndependent(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{name: "left", target: "left-target"},
|
||||
{name: "right", target: "right-target"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, test.target), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(test.target, filepath.Join(root, "alias")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
access, err := openHostAccess(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer access.close()
|
||||
if got, err := resolveHostImportPath(access, "alias"); err != nil || got != test.target {
|
||||
t.Fatalf("namespace link = %q, %v; want %q", got, err, test.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type countingReadLinkFS struct {
|
||||
filesystem fs.FS
|
||||
links fs.ReadLinkFS
|
||||
operations int
|
||||
readLinks int
|
||||
limit int
|
||||
}
|
||||
|
||||
func newCountingReadLinkFS(filesystem fs.FS, limit int) *countingReadLinkFS {
|
||||
links, ok := filesystem.(fs.ReadLinkFS)
|
||||
if !ok {
|
||||
panic("test filesystem does not support link inspection")
|
||||
}
|
||||
return &countingReadLinkFS{filesystem: filesystem, links: links, limit: limit}
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) Open(name string) (fs.File, error) { return f.filesystem.Open(name) }
|
||||
|
||||
func (f *countingReadLinkFS) Stat(name string) (fs.FileInfo, error) {
|
||||
if statter, ok := f.filesystem.(fs.StatFS); ok {
|
||||
return statter.Stat(name)
|
||||
}
|
||||
return fs.Stat(f.filesystem, name)
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if reader, ok := f.filesystem.(fs.ReadDirFS); ok {
|
||||
return reader.ReadDir(name)
|
||||
}
|
||||
return fs.ReadDir(f.filesystem, name)
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) count() error {
|
||||
f.operations++
|
||||
if f.operations > f.limit {
|
||||
return errors.New("native link operation limit exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) Lstat(name string) (fs.FileInfo, error) {
|
||||
if err := f.count(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.links.Lstat(name)
|
||||
}
|
||||
|
||||
func (f *countingReadLinkFS) ReadLink(name string) (string, error) {
|
||||
if err := f.count(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f.readLinks++
|
||||
return f.links.ReadLink(name)
|
||||
}
|
||||
|
||||
@@ -56,47 +56,15 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := path.Match(segment, entry.Name())
|
||||
if err != nil {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(site, CodeImportInvalid, "import glob pattern is invalid")}
|
||||
}
|
||||
if !matched || !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
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,
|
||||
candidate, selected, diagnostic := prepareGlobEntry(access, basePath, parent, entry, segment, last, policy)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if selected {
|
||||
nextByPath[candidate.path] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates = candidatesFromMap(nextByPath)
|
||||
if len(candidates) == 0 {
|
||||
break
|
||||
@@ -120,12 +88,53 @@ func selectGlobImportCandidates(access *filesystemAccess, basePath string, spec
|
||||
// Source allowance is charged by source admission, after this filtered set
|
||||
// reaches the traversal; that preserves repeated-source diagnostics first.
|
||||
selection := importSelection{candidates: selected}
|
||||
if len(selected) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func prepareGlobEntry(access *filesystemAccess, basePath string, parent selectedImportCandidate, entry fs.DirEntry, pattern string, last bool, policy resolvedImportPolicy) (selectedImportCandidate, bool, *Diagnostic) {
|
||||
name := entry.Name()
|
||||
matched, err := path.Match(pattern, name)
|
||||
if err != nil {
|
||||
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportInvalid, "import glob pattern is invalid")
|
||||
}
|
||||
if !matched || !policy.hidden && strings.HasPrefix(name, ".") {
|
||||
return selectedImportCandidate{}, false, nil
|
||||
}
|
||||
|
||||
accessPath := access.join(parent.accessPath, name)
|
||||
lexicalPath := access.join(parent.path, name)
|
||||
if !access.contains(accessPath) || !access.contains(lexicalPath) {
|
||||
return selectedImportCandidate{}, false, importSelectionDiagnostic(parent.site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")
|
||||
}
|
||||
target, err := access.importEntryTarget(accessPath, entry, policy.follow)
|
||||
if err != nil {
|
||||
diagnostic := globCandidateDiagnostic(err, parent.site.location, "glob match could not be inspected")
|
||||
return selectedImportCandidate{}, false, &diagnostic
|
||||
}
|
||||
if target.symlink && !policy.follow || !last && !target.dir {
|
||||
return selectedImportCandidate{}, false, nil
|
||||
}
|
||||
|
||||
physicalPath := target.identity
|
||||
// Native source identity and relative imports use the namespace spelling;
|
||||
// resolved link identities belong only to discovery.
|
||||
if access.native != nil {
|
||||
physicalPath = accessPath
|
||||
}
|
||||
if physicalPath == "" {
|
||||
physicalPath, err = access.canonical(accessPath)
|
||||
if err != nil {
|
||||
diagnostic := globFilesystemDiagnostic(err, "glob match could not be canonicalized", parent.site.location)
|
||||
return selectedImportCandidate{}, false, &diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
return selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: physicalPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: parent.site,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func explicitGlobCandidate(access *filesystemAccess, basePath, candidatePath, accessPath string, site importFieldSite, last bool) (selectedImportCandidate, bool, []Diagnostic) {
|
||||
if !access.contains(candidatePath) || !access.contains(accessPath) {
|
||||
return selectedImportCandidate{}, false, []Diagnostic{*importSelectionDiagnostic(site, CodeImportOutsideRoot, "glob traversal is outside the configuration root")}
|
||||
@@ -191,7 +200,7 @@ func readGlobEntries(access *filesystemAccess, directory string, location Locati
|
||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if missingDirectoryOpen(err) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestSelectGlobAppliesExclusionsBeforeSourceAdmissionWithoutOpeningFiles(t *
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func selectGlobForTestWithDiagnostics(t *testing.T, access *filesystemAccess, ba
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("policy diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -216,7 +216,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
limits.entries = 2
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
||||
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||
_, _, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -228,7 +229,8 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
||||
main = parseImportMain(t, mainPath)
|
||||
limits = defaultImportLimits()
|
||||
limits.sources = 2
|
||||
_, _, diagnostics = loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
||||
request = testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||
_, _, diagnostics = testAcquireImportWithLimits(request, limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,86 @@ func (c *retainedNodeCounter) add(root *document.Node) bool {
|
||||
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
|
||||
// records answer presence and origin queries; replay reconstructs value changes.
|
||||
// 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)
|
||||
}
|
||||
|
||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
|
||||
mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil)
|
||||
}
|
||||
|
||||
func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
|
||||
if incoming == nil {
|
||||
return
|
||||
@@ -337,6 +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 {
|
||||
if values == 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) {
|
||||
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}},
|
||||
{selector: sequenceIdentitySelector{
|
||||
literalSequenceSelector("collections"), anyMappingSequenceSelector(), literalSequenceSelector("records"),
|
||||
}, identityFields: []string{"id"}},
|
||||
})
|
||||
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n")
|
||||
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
|
||||
first := parseCompositionDocument(t, "source-1", "collections: {north: {records: [{id: a, value: first}]}, south: {records: [{id: x}]}}\n")
|
||||
second := parseCompositionDocument(t, "source-2", "collections: {north: {records: [{id: b, value: second}]}, south: {records: [{id: y}]}}\n")
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose diagnostics = %#v", diagnostics)
|
||||
}
|
||||
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))
|
||||
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)
|
||||
@@ -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)
|
||||
for _, application := range composed.applications {
|
||||
applyHistoryApplication(&working, application, index)
|
||||
}
|
||||
for path, want := range map[string][]string{
|
||||
"/records": {"a", "b"},
|
||||
"/other": {"x", "y"},
|
||||
"/collections/north/records": {"a", "b"},
|
||||
"/collections/south/records": {"x", "y"},
|
||||
} {
|
||||
sequence, present := documentAtPath(working, mustParsePath(t, path))
|
||||
if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) {
|
||||
@@ -53,14 +54,14 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
||||
application := composed.applications[1]
|
||||
cloned := cloneHistoryApplications(composed.applications)
|
||||
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")
|
||||
}
|
||||
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() {
|
||||
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")
|
||||
}
|
||||
origin := historyApplicationOrigin(application, addedPath, nil)
|
||||
@@ -69,10 +70,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
||||
}
|
||||
overlay := historyApplication{
|
||||
layer: LayerOverlay,
|
||||
value: parseCompositionDocument(t, "overlay", "records: []\n"),
|
||||
value: parseCompositionDocument(t, "overlay", "collections: {north: {records: []}}\n"),
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
mode := sequenceHistoryMapped
|
||||
if test.operation == sequenceModeReplace {
|
||||
mode = sequenceHistoryReplace
|
||||
}
|
||||
length := 0
|
||||
if mode == sequenceHistoryReplace {
|
||||
length = len(combined.higherItems)
|
||||
}
|
||||
application := historyApplication{
|
||||
layer: LayerOverlay,
|
||||
value: higherRoot,
|
||||
sequences: []sequenceHistoryContribution{{
|
||||
path: Keys("records"), mode: mode, length: length,
|
||||
items: combined.higherItems, replacements: combined.replacements,
|
||||
}},
|
||||
sequences: []sequenceHistoryContribution{
|
||||
sequenceHistoryContributionForCombination(Keys("records"), test.operation, higher, combined),
|
||||
},
|
||||
}
|
||||
if len(combined.higherItems) != 0 {
|
||||
combined.higherItems[0].outputIndex++
|
||||
}
|
||||
if len(combined.replacements) != 0 {
|
||||
combined.replacements[0].outputIndex++
|
||||
}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
replayed := document.Clone(lowerRoot)
|
||||
|
||||
528
internal/engine/import_acquisition.go
Normal file
528
internal/engine/import_acquisition.go
Normal file
@@ -0,0 +1,528 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
type importAdmissionOutcome uint8
|
||||
|
||||
const (
|
||||
importAdmissionReady importAdmissionOutcome = iota
|
||||
importAdmissionOptionalMissing
|
||||
)
|
||||
|
||||
type admittedImport struct {
|
||||
canonical string
|
||||
identity resourceIdentity
|
||||
nodeKey importNodeKey
|
||||
source SourceInfo
|
||||
sourceIndex int
|
||||
payload *document.Node
|
||||
declarations []importDeclarationSpec
|
||||
sequenceDeclarations []sequenceDeclarationSpec
|
||||
processing *sourceVersionProcessingResult
|
||||
}
|
||||
|
||||
type importAdmissionResult struct {
|
||||
outcome importAdmissionOutcome
|
||||
source admittedImport
|
||||
}
|
||||
|
||||
func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (*composedDocument, []SourceInfo, *resolvedFilesystemContext, []Diagnostic) {
|
||||
source := request.input.input.source
|
||||
context := request.input.input.filesystem
|
||||
main := request.main
|
||||
resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if resolved.native == nil {
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
access, err := openResolvedFilesystemAccess(resolved)
|
||||
if err != nil {
|
||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
diagnostic.Primary = nil
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
|
||||
}
|
||||
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
||||
if len(diagnostics) != 0 {
|
||||
return composition, sources, nil, diagnostics
|
||||
}
|
||||
return composition, sources, &resolved, nil
|
||||
}
|
||||
|
||||
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
|
||||
destination := request.input.input.destination
|
||||
limits := policy.importLimits
|
||||
accounting := loadState.accounting
|
||||
processing := request.processing
|
||||
basePath, mainPath := resolved.basePath, resolved.mainPath
|
||||
if tier.visited == nil {
|
||||
tier.visited = make(map[resourceIdentity]importRoute)
|
||||
}
|
||||
visited := tier.visited
|
||||
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
|
||||
_ = closeAccess()
|
||||
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} {
|
||||
info, err := access.stat(directory)
|
||||
if err != nil || !info.IsDir() {
|
||||
_ = closeAccess()
|
||||
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)}
|
||||
}
|
||||
}
|
||||
mainInfo := cloneSourceInfo(*main.source)
|
||||
mainInfo.Destination = destination.clone()
|
||||
mainIsItem := main.source.placementMode == PlacementModeSequenceItem
|
||||
if mainPath != "" {
|
||||
mainInfo.Path = mainPath
|
||||
}
|
||||
mainResource := access.identity(mainPath)
|
||||
mainNodeKey := importKeyFor(access, mainPath, mainPath == "")
|
||||
if mainPath == "" {
|
||||
mainResource = access.syntheticIdentity()
|
||||
}
|
||||
if previous, repeated := visited[mainResource]; repeated {
|
||||
_ = closeAccess()
|
||||
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += main.bytes
|
||||
accounting.nodes += countInputNodes(main.document)
|
||||
directiveContext := request.input.directiveContext
|
||||
if directiveContext == 0 {
|
||||
directiveContext = rootDirectiveContextImportsOnly
|
||||
}
|
||||
if processing != nil && processing.directiveContext != 0 {
|
||||
directiveContext = processing.directiveContext
|
||||
}
|
||||
request.input.directiveContext = directiveContext
|
||||
state := importTraversal{
|
||||
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
||||
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int),
|
||||
importGraph: newImportInclusionGraph(),
|
||||
}
|
||||
if mainIsItem {
|
||||
state.itemScope = true
|
||||
state.itemPrefix = destination.clone()
|
||||
state.itemFallback = destination.clone()
|
||||
state.scope.destination = Path{}
|
||||
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||
}
|
||||
|
||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
||||
_ = closeAccess()
|
||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||
}
|
||||
|
||||
mainIdentity := mainPath
|
||||
if mainIdentity == "" {
|
||||
mainIdentity = "<selected-source>"
|
||||
}
|
||||
mainRoute := importRoute{canonical: mainIdentity}
|
||||
state.importGraph.addNode(importGraphNode{key: mainNodeKey, canonical: mainIdentity, sourceID: mainInfo.ID, role: importRoleMain})
|
||||
mainRoute.key = mainNodeKey
|
||||
state.active[mainResource] = 0
|
||||
tier.visited[mainResource] = mainRoute
|
||||
state.stack = append(state.stack, mainRoute)
|
||||
var body *document.Node
|
||||
var declarations []importDeclarationSpec
|
||||
var sequenceDeclarations []sequenceDeclarationSpec
|
||||
var diagnostics []Diagnostic
|
||||
if processing != nil {
|
||||
body = processing.payload
|
||||
declarations = processing.imports
|
||||
sequenceDeclarations = cloneSequenceDeclarations(processing.sequences)
|
||||
} else {
|
||||
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
if mainIsItem {
|
||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain,
|
||||
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||
})
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}, 0)
|
||||
} else {
|
||||
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
placementNodes := destinationWrapperNodeCount(destination)
|
||||
if err != nil {
|
||||
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
} else if accounting.nodes+placementNodes > limits.nodes {
|
||||
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||
} else {
|
||||
accounting.nodes += placementNodes
|
||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
||||
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||
destination: destination.clone(), processing: processing,
|
||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||
})
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
scheduleAcquisitionScopes(&state.scope, indexImportDependencies(state.importGraph))
|
||||
}
|
||||
|
||||
closeErr := closeAccess()
|
||||
if len(diagnostics) != 0 {
|
||||
if mainIsItem {
|
||||
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
|
||||
}
|
||||
return nil, cloneSourceInfos(state.sources), diagnostics
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||
}
|
||||
acquisition := state.scope
|
||||
if mainIsItem {
|
||||
localScope := state.scope
|
||||
acquisition = acquisitionScope{
|
||||
owner: mainNodeKey, destination: destination.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
|
||||
processing: processing, sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations), pendingItem: &localScope,
|
||||
}},
|
||||
}
|
||||
}
|
||||
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||
if s.state.accounting.sources > s.policy.importLimits.sources {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||
return &value
|
||||
}
|
||||
if s.state.accounting.bytes > s.policy.importLimits.bytes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||
return &value
|
||||
}
|
||||
if s.state.accounting.nodes > s.policy.importLimits.nodes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
||||
policies := make([]resolvedImportPolicy, len(declarations))
|
||||
for index, spec := range declarations {
|
||||
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
||||
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)}}
|
||||
}
|
||||
policies[index] = policy
|
||||
}
|
||||
for index, spec := range declarations {
|
||||
policy := policies[index]
|
||||
var selection importSelection
|
||||
var diagnostics []Diagnostic
|
||||
switch spec.selector {
|
||||
case importSelectorPath:
|
||||
selection, diagnostics = selectNamedImportCandidates(s.access, basePath, spec, policy)
|
||||
case importSelectorDirectory:
|
||||
selection, diagnostics = selectDirectoryImportCandidates(s.access, basePath, spec, policy, s.policy.importLimits, s.state.accounting)
|
||||
case importSelectorGlob:
|
||||
selection, diagnostics = selectGlobImportCandidates(s.access, basePath, spec, policy, s.policy.importLimits, s.state.accounting)
|
||||
default:
|
||||
location := spec.location
|
||||
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import selector is invalid", &location)}
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
childDestination := appendDestination(parentDestination, policy.destination)
|
||||
// Count admitted records rather than lexical candidates: optional missing
|
||||
// paths are omitted during admission, and a partially present list is not empty.
|
||||
sourcesBefore := len(s.sources)
|
||||
for _, candidate := range selection.candidates {
|
||||
if diagnostics := s.visitPath(candidate, spec, policy, childDestination, policy.destination, depth+1); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
if !policy.required && len(s.sources) == sourcesBefore {
|
||||
s.recordEmptyImportSelection(sourceIndex, spec)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) recordEmptyImportSelection(sourceIndex int, spec importDeclarationSpec) {
|
||||
// The declaring source's position survives descendant appends and optional
|
||||
// missing-source rollback, unlike a pointer into the source slice.
|
||||
s.sources[sourceIndex].importSelections = append(s.sources[sourceIndex].importSelections, importSelectionEvidence{
|
||||
path: spec.path.clone(), location: spec.location,
|
||||
selector: spec.selector, outcome: importSelectionNone,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *importTraversal) admitImportSource(canonical string, identity resourceIdentity, nodeKey importNodeKey, candidate selectedImportCandidate, policy resolvedImportPolicy, destination Path, location Location) (importAdmissionResult, []Diagnostic) {
|
||||
sourceID := "source-" + strconv.Itoa(s.state.accounting.sources+1)
|
||||
kind := SourceKindFile
|
||||
label := candidate.label
|
||||
if label == "" {
|
||||
label = candidate.path
|
||||
}
|
||||
if s.access.native != nil {
|
||||
kind = SourceKindFilesystem
|
||||
label = s.access.label + "/" + canonical
|
||||
}
|
||||
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.sourceDestination(destination), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
|
||||
if policy.placement == PlacementModeSequenceItem {
|
||||
info.placementMode = PlacementModeSequenceItem
|
||||
}
|
||||
// A source's own item placement still has an exact sequence-container
|
||||
// destination. Only an unresolved enclosing item index makes the path a
|
||||
// fallback container for this snapshot.
|
||||
info.destinationUnresolved = s.itemScope
|
||||
if kind == SourceKindFilesystem {
|
||||
info.namespace = s.access.token
|
||||
}
|
||||
sourceIndex := len(s.sources)
|
||||
s.sources = append(s.sources, info)
|
||||
data, skipped, diagnostic := s.readImport(canonical, sourceID, policy.required)
|
||||
if diagnostic != nil {
|
||||
return importAdmissionResult{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if skipped {
|
||||
s.sources = s.sources[:len(s.sources)-1]
|
||||
return importAdmissionResult{outcome: importAdmissionOptionalMissing}, nil
|
||||
}
|
||||
s.state.accounting.sources++
|
||||
|
||||
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
|
||||
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||
})
|
||||
if len(issues) != 0 {
|
||||
return importAdmissionResult{}, parserDiagnostics(sourceID, issues)
|
||||
}
|
||||
nodeCount := countInputNodes(root)
|
||||
if s.state.accounting.nodes+nodeCount > s.policy.importLimits.nodes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)
|
||||
return importAdmissionResult{}, []Diagnostic{value}
|
||||
}
|
||||
s.state.accounting.nodes += nodeCount
|
||||
|
||||
directiveContext := s.request.input.directiveContext
|
||||
if directiveContext == 0 {
|
||||
directiveContext = rootDirectiveContextImportsOnly
|
||||
}
|
||||
versionDestination := destination
|
||||
if s.itemScope {
|
||||
versionDestination = appendDestination(s.itemPrefix.Index(0), destination)
|
||||
}
|
||||
var processing sourceVersionProcessingResult
|
||||
if s.state.versionState != nil && s.state.versionState.mode != sourceVersionModeDefault {
|
||||
processed, failures := s.state.versionState.processWithDirectiveContext(root, sourceVersionContribution, versionDestination, sourceID, policy.placement, directiveContext)
|
||||
if len(failures) != 0 {
|
||||
return importAdmissionResult{}, failures
|
||||
}
|
||||
processing = processed
|
||||
} else {
|
||||
processed := processSourceVersionWithDirectiveContext(root, s.request.input.protectionRule, sourceVersionContribution, versionDestination, sourceVersionModeDefault, directiveContext)
|
||||
if len(processed.diagnostics) != 0 {
|
||||
return importAdmissionResult{}, processed.diagnostics
|
||||
}
|
||||
processing = processed
|
||||
}
|
||||
return importAdmissionResult{source: admittedImport{
|
||||
canonical: canonical, identity: identity, nodeKey: nodeKey,
|
||||
source: cloneSourceInfo(info), sourceIndex: sourceIndex, payload: processing.payload,
|
||||
declarations: processing.imports,
|
||||
sequenceDeclarations: cloneSequenceDeclarations(processing.sequences),
|
||||
processing: &processing,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec importDeclarationSpec, policy resolvedImportPolicy, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||
location := candidate.site.location
|
||||
if location.SourceID == "" && location.Line == 0 && location.Column == 0 {
|
||||
location = spec.location
|
||||
}
|
||||
if !s.access.contains(candidate.accessPath) {
|
||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
|
||||
}
|
||||
canonical, err := s.access.canonical(candidate.accessPath)
|
||||
if err != nil {
|
||||
if !policy.required && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
code := CodeImportInvalid
|
||||
message := "import target could not be canonicalized"
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
code, message = CodeImportMissing, "required import target does not exist"
|
||||
} else if errors.Is(err, fs.ErrPermission) {
|
||||
message = "import target cannot be accessed"
|
||||
}
|
||||
return []Diagnostic{importDiagnostic(code, message, &location)}
|
||||
}
|
||||
if !s.access.contains(canonical) {
|
||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
|
||||
}
|
||||
if depth > s.policy.importLimits.depth {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
|
||||
}
|
||||
|
||||
currentRoute := s.currentRoute(location)
|
||||
identity := s.access.identity(canonical)
|
||||
nodeKey := importKeyFor(s.access, canonical, false)
|
||||
importerKey := s.currentImportNode()
|
||||
if activeIndex, ok := s.active[identity]; ok {
|
||||
closingEdge := s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||
if diagnostic, ok := s.importGraph.scheduleCycle(closingEdge, location); ok {
|
||||
return []Diagnostic{diagnostic}
|
||||
}
|
||||
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
|
||||
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
|
||||
}
|
||||
if previous, ok := s.tier.visited[identity]; ok {
|
||||
s.importGraph.addEdge(importerKey, nodeKey, location)
|
||||
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
|
||||
}
|
||||
if s.state.accounting.sources+1 > s.policy.importLimits.sources {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
|
||||
}
|
||||
|
||||
if _, err := s.access.relative(canonical); err != nil {
|
||||
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
|
||||
}
|
||||
admission, diagnostics := s.admitImportSource(canonical, identity, nodeKey, candidate, policy, destination, location)
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
if admission.outcome == importAdmissionOptionalMissing {
|
||||
return nil
|
||||
}
|
||||
admitted := admission.source
|
||||
|
||||
placementDestination := destination
|
||||
mode := PlacementModeMapping
|
||||
if policy.placement == PlacementModeSequenceItem {
|
||||
mode = PlacementModeSequenceItem
|
||||
}
|
||||
role := importRoleMapping
|
||||
if mode == PlacementModeSequenceItem {
|
||||
role = importRoleItem
|
||||
}
|
||||
s.importGraph.addNode(importGraphNode{key: admitted.nodeKey, canonical: admitted.canonical, sourceID: admitted.source.ID, role: role})
|
||||
s.importGraph.addEdge(importerKey, admitted.nodeKey, location)
|
||||
if mode == PlacementModeSequenceItem {
|
||||
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
|
||||
s.active[admitted.identity] = len(s.stack)
|
||||
s.tier.visited[admitted.identity] = route
|
||||
s.stack = append(s.stack, route)
|
||||
diagnostics := s.visitItemSource(admitted, destination, relativeDestination, depth)
|
||||
delete(s.active, admitted.identity)
|
||||
s.stack = s.stack[:len(s.stack)-1]
|
||||
return diagnostics
|
||||
}
|
||||
_, placed, err := placeDocument(admitted.payload, placementDestination, admitted.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
if err != nil {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
|
||||
}
|
||||
placementNodes := destinationWrapperNodeCount(placementDestination)
|
||||
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
|
||||
}
|
||||
s.state.accounting.nodes += placementNodes
|
||||
route := importRoute{canonical: admitted.canonical, locations: currentRoute.locations, key: admitted.nodeKey}
|
||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(admitted.source), payload: admitted.payload, document: placed,
|
||||
identity: admitted.nodeKey, role: role, scopeOwner: s.scope.owner,
|
||||
destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: admitted.processing,
|
||||
sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
|
||||
})
|
||||
s.active[admitted.identity] = len(s.stack)
|
||||
s.tier.visited[admitted.identity] = route
|
||||
s.stack = append(s.stack, route)
|
||||
diagnostics = s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, destination, admitted.sourceIndex)
|
||||
delete(s.active, admitted.identity)
|
||||
s.stack = s.stack[:len(s.stack)-1]
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func (s *importTraversal) currentImportNode() importNodeKey {
|
||||
if len(s.stack) != 0 {
|
||||
return s.stack[len(s.stack)-1].key
|
||||
}
|
||||
key := s.scope.owner
|
||||
if _, exists := s.importGraph.index[key]; !exists {
|
||||
s.importGraph.addNode(importGraphNode{key: key, canonical: "<selected-source>", role: importRoleMain})
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (s *importTraversal) sourceDestination(destination Path) Path {
|
||||
if !s.itemScope {
|
||||
return destination.clone()
|
||||
}
|
||||
// Until the enclosing item receives its assigned index, only the collection
|
||||
// container is exact. Keep the local suffix in the acquisition projection.
|
||||
return s.itemFallback.clone()
|
||||
}
|
||||
|
||||
func (s *importTraversal) readImport(name, sourceID string, required bool) ([]byte, bool, *Diagnostic) {
|
||||
remaining := s.policy.importLimits.bytes - s.state.accounting.bytes
|
||||
limit := min(int64(maxSourceBytes), remaining)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
|
||||
if failure != nil {
|
||||
if !required && failure.missingTarget {
|
||||
return nil, true, nil
|
||||
}
|
||||
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||
return nil, false, &value
|
||||
}
|
||||
s.state.accounting.bytes += int64(len(data))
|
||||
return data, false, nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) currentRoute(location Location) importRoute {
|
||||
locations := make([]Location, 0, len(s.stack)+1)
|
||||
for _, route := range s.stack {
|
||||
if len(route.locations) != 0 {
|
||||
locations = append(locations, route.locations[len(route.locations)-1])
|
||||
}
|
||||
}
|
||||
locations = append(locations, location)
|
||||
return importRoute{locations: locations}
|
||||
}
|
||||
@@ -2,11 +2,54 @@ package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -157,7 +157,20 @@ func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Locati
|
||||
}, 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))
|
||||
for index, contribution := range scope.contributions {
|
||||
if contribution.scopeOwner != scope.owner {
|
||||
@@ -166,7 +179,7 @@ func scheduleAcquisitionScope(scope acquisitionScope, graph importInclusionGraph
|
||||
slots = append(slots, index)
|
||||
}
|
||||
scheduled := scope
|
||||
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, graph)
|
||||
scheduled.contributions = scheduleContributionSlots(scope.contributions, slots, dependencies)
|
||||
return scheduled
|
||||
}
|
||||
|
||||
@@ -179,10 +192,10 @@ func scheduleAcquisitionGroup(scopes []acquisitionScope, graph importInclusionGr
|
||||
for index := range slots {
|
||||
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 {
|
||||
return append([]acquiredContribution(nil), contributions...)
|
||||
}
|
||||
@@ -193,14 +206,14 @@ func scheduleContributionSlots(contributions []acquiredContribution, slots []int
|
||||
positions[contribution.identity] = position
|
||||
keys[position] = contribution.identity.dependencyKey()
|
||||
}
|
||||
edges := make([]dependencyEdge, 0, len(graph.edges))
|
||||
for _, edge := range graph.edges {
|
||||
importer, importerOK := positions[edge.importer]
|
||||
imported, importedOK := positions[edge.imported]
|
||||
if importerOK && importedOK {
|
||||
var edges []dependencyEdge
|
||||
for importerKey, importer := range positions {
|
||||
for _, importedKey := range dependencies[importerKey] {
|
||||
if imported, exists := positions[importedKey]; exists {
|
||||
edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer})
|
||||
}
|
||||
}
|
||||
}
|
||||
order, _ := scheduleDependencies(keys, edges)
|
||||
if len(order) != len(keys) {
|
||||
return append([]acquiredContribution(nil), contributions...)
|
||||
|
||||
277
internal/engine/import_projection.go
Normal file
277
internal/engine/import_projection.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
func prependCompositionDiagnosticPaths(diagnostics []Diagnostic, prefix Path) []Diagnostic {
|
||||
cloned := append([]Diagnostic(nil), diagnostics...)
|
||||
for index := range cloned {
|
||||
if !isCompositionDiagnostic(cloned[index]) {
|
||||
continue
|
||||
}
|
||||
cloned[index].Path = appendDestination(prefix, cloned[index].Path)
|
||||
cloned[index].Primary = cloneLocation(cloned[index].Primary)
|
||||
cloned[index].Related = cloneLocations(cloned[index].Related)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func isCompositionDiagnostic(diagnostic Diagnostic) bool {
|
||||
return diagnostic.Code == CodeComposeConflict || diagnostic.Code == CodeComposeInvalid
|
||||
}
|
||||
|
||||
// On a composition failure only the earlier contributions are needed to locate
|
||||
// this item. Project one enclosing scope at a time as traversal unwinds; syntax
|
||||
// and access diagnostics keep their original source-local coordinates.
|
||||
func projectItemDiagnosticPaths(diagnostics []Diagnostic, parent acquisitionScope, destination Path, beforeContribution int) []Diagnostic {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if isCompositionDiagnostic(diagnostic) {
|
||||
prefix := parent
|
||||
prefix.contributions = parent.contributions[:beforeContribution]
|
||||
index := priorSequenceLength([]acquisitionScope{prefix}, destination)
|
||||
return prependCompositionDiagnosticPaths(diagnostics, destination.Index(index))
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
// This scan is confined to failed loads, not the successful construction path.
|
||||
func priorSequenceLength(scopes []acquisitionScope, path Path) int {
|
||||
length := 0
|
||||
for _, scope := range scopes {
|
||||
for _, contribution := range scope.contributions {
|
||||
if node, present := documentAtPath(contribution.document, path); present && node.Kind == document.Sequence {
|
||||
length += len(node.Sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
func projectPriorInputDiagnosticPaths(diagnostics []Diagnostic, scopes []acquisitionScope) []Diagnostic {
|
||||
cloned := cloneDiagnostics(diagnostics)
|
||||
for index := range cloned {
|
||||
if !isCompositionDiagnostic(cloned[index]) {
|
||||
continue
|
||||
}
|
||||
for position, segment := range cloned[index].Path.segments {
|
||||
if segment.kind != pathSegmentIndex {
|
||||
continue
|
||||
}
|
||||
container := Path{segments: cloned[index].Path.segments[:position]}
|
||||
cloned[index].Path.segments[position].index += priorSequenceLength(scopes, container)
|
||||
// Registration does not address paths through item indexes. A prior
|
||||
// input shifts the outer collection, not its item-local sequences.
|
||||
break
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
|
||||
if composed == nil {
|
||||
return
|
||||
}
|
||||
applications := composed.applications
|
||||
projectedApplications := make([]historyApplication, 0, len(applications))
|
||||
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||
for sourceIndex, contribution := range scope.contributions {
|
||||
if contribution.nested == nil {
|
||||
projectedApplications = append(projectedApplications, applications[sourceIndex])
|
||||
continue
|
||||
}
|
||||
itemIndex := 0
|
||||
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found {
|
||||
if projected, ok := assigned.outputForInput(0); ok {
|
||||
itemIndex = projected
|
||||
}
|
||||
}
|
||||
prefix := contribution.destination.Index(itemIndex)
|
||||
if sourceIndex < len(applications) {
|
||||
outer := applications[sourceIndex]
|
||||
structural := document.Clone(outer.value)
|
||||
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > 0 {
|
||||
// The retained fragment is still a local singleton. Its assigned
|
||||
// outer index belongs to the projection, not this source tree.
|
||||
node.Sequence[0] = &document.Node{Kind: document.Mapping, Location: node.Sequence[0].Location}
|
||||
}
|
||||
outer.value = structural
|
||||
projectedApplications = append(projectedApplications, outer)
|
||||
for _, nested := range contribution.nested.applications {
|
||||
projected := nested
|
||||
projected.path = appendDestination(prefix, nested.path)
|
||||
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
|
||||
projected.sequencesByPath = indexSequenceHistoryContributions(projected.sequences)
|
||||
projectedApplications = append(projectedApplications, projected)
|
||||
}
|
||||
}
|
||||
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
|
||||
}
|
||||
composed.applications = projectedApplications
|
||||
projectNestedCollectionPolicies(composed, scope, ranges)
|
||||
}
|
||||
|
||||
func projectNestedCollectionPolicies(composed *composedDocument, scope acquisitionScope, ranges map[sequenceRangeKey]sequenceRange) {
|
||||
if composed == nil {
|
||||
return
|
||||
}
|
||||
type itemPolicyProjection struct {
|
||||
destination Path
|
||||
policy collectionPolicy
|
||||
base int
|
||||
}
|
||||
projected := make(map[string]*itemPolicyProjection)
|
||||
var prefixLengths map[string]int
|
||||
for sourceIndex, contribution := range scope.contributions {
|
||||
if prefixLengths != nil && sourceIndex > 0 {
|
||||
addSequencePrefixLengths(scope.contributions[sourceIndex-1].document, Path{}, prefixLengths)
|
||||
}
|
||||
if contribution.nested == nil || len(contribution.nested.collectionPolicies.collections) == 0 {
|
||||
continue
|
||||
}
|
||||
key := contribution.destination.String()
|
||||
assigned := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: key}]
|
||||
itemIndex, found := assigned.outputForInput(0)
|
||||
if !found {
|
||||
// Normal composition supplies the assignment directly. For a
|
||||
// handoff without ranges, scan each preceding tree only once.
|
||||
if prefixLengths == nil {
|
||||
prefixLengths = make(map[string]int)
|
||||
for _, previous := range scope.contributions[:sourceIndex] {
|
||||
addSequencePrefixLengths(previous.document, Path{}, prefixLengths)
|
||||
}
|
||||
}
|
||||
itemIndex = prefixLengths[key]
|
||||
}
|
||||
working, initialized := projected[key]
|
||||
if !initialized {
|
||||
// Read one owned copy per destination, then add each incoming
|
||||
// record without recopying earlier records and their scopes.
|
||||
policy, _ := composed.collectionPolicies.at(contribution.destination)
|
||||
working = &itemPolicyProjection{destination: contribution.destination, policy: policy, base: len(policy.recordOrder)}
|
||||
projected[key] = working
|
||||
}
|
||||
working.policy.setRecordPolicyAt(working.base+itemIndex, collectionRecordPolicy{
|
||||
owner: newCollectionRecordPolicy().owner,
|
||||
scope: contribution.nested.collectionPolicies,
|
||||
})
|
||||
}
|
||||
for _, working := range projected {
|
||||
composed.collectionPolicies.set(working.destination, working.policy)
|
||||
}
|
||||
}
|
||||
|
||||
func addSequencePrefixLengths(node *document.Node, path Path, lengths map[string]int) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == document.Sequence {
|
||||
lengths[path.String()] += len(node.Sequence)
|
||||
return
|
||||
}
|
||||
// Contribution destinations select mapping paths, not sequence members.
|
||||
for _, entry := range node.Mapping {
|
||||
addSequencePrefixLengths(entry.Value, path.Key(entry.Key), lengths)
|
||||
}
|
||||
}
|
||||
|
||||
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
|
||||
var visit func(*document.Node, Path)
|
||||
visit = func(node *document.Node, path Path) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if origin, ok := origins[path.String()]; ok {
|
||||
target[appendDestination(prefix, path).String()] = cloneOrigin(origin)
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
visit(entry.Value, path.Key(entry.Key))
|
||||
}
|
||||
for index, child := range node.Sequence {
|
||||
visit(child, path.Index(index))
|
||||
}
|
||||
}
|
||||
visit(root, Path{})
|
||||
}
|
||||
|
||||
func projectAcquiredDestinations(composed *composedDocument, scope acquisitionScope, sources []SourceInfo) {
|
||||
byID := make(map[string]int, len(sources))
|
||||
for index := range sources {
|
||||
byID[sources[index].ID] = index
|
||||
}
|
||||
var projectScope func(*composedDocument, acquisitionScope, Path, bool, string)
|
||||
projectScope = func(currentComposition *composedDocument, current acquisitionScope, prefix Path, insideItem bool, parentSourceID string) {
|
||||
ranges := indexSequenceRanges(currentComposition.sequenceRanges)
|
||||
for sourceIndex, contribution := range current.contributions {
|
||||
if contribution.source.ID == parentSourceID {
|
||||
continue
|
||||
}
|
||||
contributionPrefix := prefix
|
||||
contributionInsideItem := insideItem
|
||||
itemIndex := 0
|
||||
if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found {
|
||||
if projected, ok := assigned.outputForInput(0); ok {
|
||||
itemIndex = projected
|
||||
}
|
||||
}
|
||||
if contribution.source.placementMode == PlacementModeSequenceItem {
|
||||
container := appendDestination(contributionPrefix, contribution.destination)
|
||||
contribution.source.Destination = container.clone()
|
||||
contribution.source.destinationUnresolved = false
|
||||
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
|
||||
if position, ok := byID[contribution.source.ID]; ok {
|
||||
sources[position].Destination = container
|
||||
sources[position].destinationUnresolved = false
|
||||
}
|
||||
contributionPrefix = appendDestination(container, Path{}.Index(itemIndex))
|
||||
contributionInsideItem = true
|
||||
projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex)
|
||||
} else if contributionInsideItem {
|
||||
exactDestination := appendDestination(contributionPrefix, contribution.destination)
|
||||
contribution.source.Destination = exactDestination.clone()
|
||||
contribution.source.destinationUnresolved = false
|
||||
current.contributions[sourceIndex].source = cloneSourceInfo(contribution.source)
|
||||
if position, ok := byID[contribution.source.ID]; ok {
|
||||
sources[position].Destination = exactDestination
|
||||
sources[position].destinationUnresolved = false
|
||||
}
|
||||
projectSourceVersionEvidence(contribution.processing, exactDestination, nil, sourceIndex)
|
||||
} else {
|
||||
projectSourceVersionEvidence(contribution.processing, contribution.destination, ranges, sourceIndex)
|
||||
}
|
||||
if contribution.nested != nil && contribution.nested.acquisition != nil {
|
||||
for _, nestedScope := range contribution.nested.acquisition {
|
||||
projectScope(contribution.nested, nestedScope, contributionPrefix, contributionInsideItem, contribution.source.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
projectScope(composed, scope, Path{}, false, "")
|
||||
}
|
||||
|
||||
func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, destination Path, ranges sequenceRangeIndex, sourceIndex int) {
|
||||
if processing != nil && processing.evidence != nil {
|
||||
evidence := processing.evidence
|
||||
evidence.destination = destination.clone()
|
||||
// Rebuild from source-local paths on each handoff: an input can be
|
||||
// composed first on its own and then at a different offset in its tier.
|
||||
evidence.projectedPaths = nil
|
||||
paths := evidence.sensitivityPaths()
|
||||
for index, path := range paths {
|
||||
for position := range path.segments {
|
||||
prefix := Path{segments: path.segments[:position]}
|
||||
assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: prefix.String()}]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
item, ok := schemaIndex(path.segments[position])
|
||||
if ok && path.segments[position].kind != pathSegmentKey {
|
||||
if outputIndex, projected := assigned.outputForInput(item); projected {
|
||||
path.segments[position] = pathSegment{kind: pathSegmentIndex, index: outputIndex}
|
||||
}
|
||||
}
|
||||
}
|
||||
paths[index] = path
|
||||
}
|
||||
evidence.projectedPaths = paths
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
287
internal/engine/import_scope.go
Normal file
287
internal/engine/import_scope.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
func compositionTraversal(policy sequencePolicy, rule *VersionRule, versionState *sourceVersionState, accounting *importAccounting, graph importInclusionGraph) importTraversal {
|
||||
return importTraversal{
|
||||
request: importAcquisitionRequest{input: inputAcquisitionRequest{protectionRule: rule}},
|
||||
policy: &acquisitionPolicy{sequencePolicy: policy, importLimits: defaultImportLimits()},
|
||||
state: &acquisitionState{accounting: accounting, versionState: versionState},
|
||||
tier: &tierAcquisitionState{},
|
||||
importGraph: graph.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// composeAcquiredInputWithPolicy is the per-input adapter from retained
|
||||
// acquisition scopes to the established strict-composition behavior. It uses
|
||||
// the already acquired documents and graph; source handles are closed before
|
||||
// this handoff.
|
||||
func composeAcquiredInputWithPolicies(input assembledInput, lower collectionPolicyScope, policy sequencePolicy) (*composedDocument, []Diagnostic) {
|
||||
if input.composition == nil || input.composition.root != nil || len(input.composition.acquisition) == 0 {
|
||||
return input.composition, nil
|
||||
}
|
||||
acquired := input.composition
|
||||
if len(acquired.acquisition) != 1 {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "acquired input has an invalid composition scope", nil)}
|
||||
}
|
||||
scope := cloneAcquisitionScope(acquired.acquisition[0])
|
||||
var rule *VersionRule
|
||||
if input.versionState != nil {
|
||||
rule = input.versionState.effectiveRule
|
||||
}
|
||||
accounting := input.accounting
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{sources: len(input.sources), bytes: acquired.inputBytes, nodes: acquired.inputNodes}
|
||||
}
|
||||
traversal := compositionTraversal(policy, rule, input.versionState, accounting, acquired.importGraph)
|
||||
if len(scope.contributions) == 1 && scope.contributions[0].role == importRoleMain && scope.contributions[0].pendingItem != nil {
|
||||
main := scope.contributions[0]
|
||||
localScope := cloneAcquisitionScope(*main.pendingItem)
|
||||
if diagnostics := traversal.composePendingItems(&localScope); len(diagnostics) != 0 {
|
||||
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
|
||||
}
|
||||
documents := make([]*document.Node, 0, len(localScope.contributions))
|
||||
for _, contribution := range localScope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
completed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
policy, localScope.collectionPolicies,
|
||||
acquiredSequenceDeclarations(localScope), documents...,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, prependCompositionDiagnosticPaths(diagnostics, main.destination.Index(0))
|
||||
}
|
||||
if input.versionState != nil {
|
||||
protectionRule := input.versionState.localRule
|
||||
if input.versionState.mode == sourceVersionModeDefault && protectionRule != nil {
|
||||
prefix := main.destination.Index(0)
|
||||
relative, inside := relativeHistoryPath(prefix, protectionRule.Path)
|
||||
if !inside || !matchesPathShape(Path{segments: protectionRule.Path.segments[:len(prefix.segments)]}, prefix) {
|
||||
protectionRule = nil
|
||||
} else {
|
||||
scoped := *protectionRule
|
||||
scoped.Path = relative
|
||||
protectionRule = &scoped
|
||||
}
|
||||
}
|
||||
if diagnostic := importedVersionClaim(documents, localScope, completed, protectionRule); diagnostic != nil {
|
||||
if rule != nil {
|
||||
diagnostic.Path = rule.Path.clone()
|
||||
}
|
||||
return nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(localScope)}
|
||||
projectNestedEvidence(completed, localScope)
|
||||
_, placed, err := placeDocumentWithMode(completed.root, main.destination, PlacementModeSequenceItem, main.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
}
|
||||
if !main.placementCharged {
|
||||
placementNodes := destinationWrapperNodeCountForMode(main.destination, PlacementModeSequenceItem)
|
||||
if accounting.nodes+placementNodes > traversal.policy.importLimits.nodes {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", main.source.ImportedFrom)}
|
||||
}
|
||||
accounting.nodes += placementNodes
|
||||
}
|
||||
composition, diagnostics := composeDocumentsWithPolicy(policy, placed)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
synthetic := acquisitionScope{owner: main.scopeOwner, contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(main.source), payload: completed.root, document: placed,
|
||||
identity: main.identity, role: importRoleMain, scopeOwner: main.scopeOwner,
|
||||
destination: main.destination.clone(), processing: main.processing, nested: completed,
|
||||
}}}
|
||||
projectNestedEvidence(composition, synthetic)
|
||||
projectAcquiredDestinations(composition, synthetic, input.sources)
|
||||
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)}
|
||||
composition.inputBytes = accounting.bytes
|
||||
composition.inputNodes = accounting.nodes
|
||||
composition.importGraph = acquired.importGraph.clone()
|
||||
return composition, nil
|
||||
}
|
||||
return composeAcquiredScopeWithPolicy(
|
||||
&scope, lower, policy, acquired.importGraph, input.sources, rule, input.versionState, accounting,
|
||||
)
|
||||
}
|
||||
|
||||
// composeAcquiredScopeWithPolicy is shared by per-input and underlay-group
|
||||
// handoffs. Lower policy is carried separately from registered application
|
||||
// constraints; current strict composition continues to use its existing rules.
|
||||
func composeAcquiredScopeWithPolicy(
|
||||
scope *acquisitionScope,
|
||||
lower collectionPolicyScope,
|
||||
policy sequencePolicy,
|
||||
graph importInclusionGraph,
|
||||
sources []SourceInfo,
|
||||
rule *VersionRule,
|
||||
versionState *sourceVersionState,
|
||||
accounting *importAccounting,
|
||||
) (*composedDocument, []Diagnostic) {
|
||||
traversal := compositionTraversal(policy, rule, versionState, accounting, graph)
|
||||
if diagnostics := traversal.composePendingItems(scope); len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
documents := make([]*document.Node, 0, len(scope.contributions))
|
||||
for _, contribution := range scope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
composition, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
policy, lower, acquiredSequenceDeclarations(*scope), documents...,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
if diagnostic := importedVersionClaim(documents, *scope, composition, rule); diagnostic != nil {
|
||||
return nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
projectNestedEvidence(composition, *scope)
|
||||
projectAcquiredDestinations(composition, *scope, sources)
|
||||
composition.acquisition = []acquisitionScope{cloneAcquisitionScope(*scope)}
|
||||
composition.inputBytes = accounting.bytes
|
||||
composition.inputNodes = accounting.nodes
|
||||
composition.importGraph = graph.clone()
|
||||
return composition, nil
|
||||
}
|
||||
|
||||
func reservePendingItemPlacements(scope *acquisitionScope, limits importLimits, accounting *importAccounting) *Diagnostic {
|
||||
if scope == nil {
|
||||
return nil
|
||||
}
|
||||
for index := range scope.contributions {
|
||||
contribution := &scope.contributions[index]
|
||||
if contribution.pendingItem == nil {
|
||||
continue
|
||||
}
|
||||
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, PlacementModeSequenceItem)
|
||||
if accounting.nodes+placementNodes > limits.nodes {
|
||||
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)
|
||||
return &diagnostic
|
||||
}
|
||||
accounting.nodes += placementNodes
|
||||
contribution.placementCharged = true
|
||||
if diagnostic := reservePendingItemPlacements(contribution.pendingItem, limits, accounting); diagnostic != nil {
|
||||
return diagnostic
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) visitItemSource(admitted admittedImport, destination, relativeDestination Path, depth int) []Diagnostic {
|
||||
parentScope := s.scope
|
||||
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
||||
s.scope = acquisitionScope{owner: admitted.nodeKey}
|
||||
s.itemScope = true
|
||||
if parentItemScope {
|
||||
s.itemPrefix = appendDestination(parentPrefix, destination)
|
||||
} else {
|
||||
s.itemPrefix = destination.clone()
|
||||
s.itemFallback = destination.clone()
|
||||
}
|
||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(admitted.source), payload: admitted.payload, document: admitted.payload,
|
||||
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: admitted.nodeKey,
|
||||
processing: admitted.processing, sequenceDeclarations: cloneSequenceDeclarations(admitted.sequenceDeclarations),
|
||||
})
|
||||
diagnostics := s.walkDeclarations(s.access.dir(admitted.canonical), admitted.declarations, depth, Path{}, admitted.sourceIndex)
|
||||
itemScope := s.scope
|
||||
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
itemScope.destination = Path{}
|
||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(admitted.source), payload: admitted.payload,
|
||||
identity: admitted.nodeKey, role: importRoleItem, scopeOwner: parentScope.owner,
|
||||
destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: admitted.processing,
|
||||
pendingItem: &itemScope,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic {
|
||||
for index := range scope.contributions {
|
||||
contribution := &scope.contributions[index]
|
||||
if contribution.pendingItem == nil {
|
||||
continue
|
||||
}
|
||||
itemScope := contribution.pendingItem
|
||||
if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 {
|
||||
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
|
||||
}
|
||||
documents := make([]*document.Node, 0, len(itemScope.contributions))
|
||||
for _, nested := range itemScope.contributions {
|
||||
documents = append(documents, nested.document)
|
||||
}
|
||||
// An item scope is below a sequence index. Application selectors match
|
||||
// mapping-only effective paths, so none can apply inside this scope.
|
||||
// Its YAML declarations remain relative to the owning record.
|
||||
completed, diagnostics := composeDocumentsWithCollectionPolicy(
|
||||
sequencePolicy{}, itemScope.collectionPolicies,
|
||||
acquiredSequenceDeclarations(*itemScope), documents...,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination, index)
|
||||
}
|
||||
// Version ownership is checked by the enclosing composition once this
|
||||
// item has its effective coordinates, not against its local field names.
|
||||
completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)}
|
||||
projectNestedEvidence(completed, *itemScope)
|
||||
_, placed, err := placeDocumentWithMode(completed.root, contribution.destination, PlacementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
if err != nil {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)}
|
||||
}
|
||||
placementNodes := destinationWrapperNodeCountForMode(contribution.destination, PlacementModeSequenceItem)
|
||||
if !contribution.placementCharged {
|
||||
if s.state.accounting.nodes+placementNodes > s.policy.importLimits.nodes {
|
||||
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)}
|
||||
}
|
||||
s.state.accounting.nodes += placementNodes
|
||||
}
|
||||
contribution.payload = completed.root
|
||||
contribution.document = placed
|
||||
contribution.nested = completed
|
||||
contribution.pendingItem = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func acquiredSequenceDeclarations(scope acquisitionScope) []sequenceDeclarationSpec {
|
||||
var declarations []sequenceDeclarationSpec
|
||||
for _, contribution := range scope.contributions {
|
||||
projected := cloneSequenceDeclarations(contribution.sequenceDeclarations)
|
||||
for index := range projected {
|
||||
projected[index].destination = appendDestination(contribution.relativeDestination, projected[index].destination)
|
||||
}
|
||||
declarations = append(declarations, projected...)
|
||||
}
|
||||
return declarations
|
||||
}
|
||||
|
||||
func importedVersionClaim(documents []*document.Node, scope acquisitionScope, composed *composedDocument, rule *VersionRule) *Diagnostic {
|
||||
if composed == nil || rule == nil {
|
||||
return nil
|
||||
}
|
||||
mainIndex := -1
|
||||
for index, contribution := range scope.contributions {
|
||||
if contribution.role == importRoleMain {
|
||||
mainIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
ranges := indexSequenceRanges(composed.sequenceRanges)
|
||||
for index, contribution := range documents {
|
||||
if index == mainIndex {
|
||||
continue
|
||||
}
|
||||
if node := fragmentVersionClaimWithIndex(contribution, rule, index, ranges); node != nil {
|
||||
location := publicLocation(node.Location)
|
||||
return &Diagnostic{
|
||||
Code: CodeImportVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "imported fragments cannot contribute to the schema-version path", Primary: &location,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -26,7 +26,6 @@ type selectedImportCandidate struct {
|
||||
// importSelection contains lexical candidates only; admission assigns source identity later.
|
||||
type importSelection struct {
|
||||
candidates []selectedImportCandidate
|
||||
outcome importSelectionOutcome
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
selection := importSelection{candidates: candidates}
|
||||
if len(candidates) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
@@ -139,72 +135,23 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
||||
}
|
||||
entry := frame.entries[frame.next]
|
||||
frame.next++
|
||||
accessPath := access.join(frame.directory.accessPath, entry.Name())
|
||||
lexicalPath := access.join(frame.directory.path, entry.Name())
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
|
||||
result, diagnostic := prepareImportDirectoryEntry(access, basePath, *frame, entry, policy, limits, accounting, activeDirectories)
|
||||
if diagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
switch result.kind {
|
||||
case importDirectoryEntrySkip:
|
||||
continue
|
||||
case importDirectoryEntryFile:
|
||||
if _, exists := seenCandidates[result.candidate.path]; exists {
|
||||
continue
|
||||
}
|
||||
target, entryErr := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
||||
if entryErr != nil {
|
||||
if errors.Is(entryErr, fs.ErrNotExist) {
|
||||
continue
|
||||
seenCandidates[result.candidate.path] = struct{}{}
|
||||
discovered = append(discovered, result.candidate)
|
||||
case importDirectoryEntryDirectory:
|
||||
activeDirectories[result.frame.identity] = struct{}{}
|
||||
stack = append(stack, result.frame)
|
||||
}
|
||||
return importSelection{}, []Diagnostic{*importDirectoryTargetDiagnostic(frame.directory.site, entryErr)}
|
||||
}
|
||||
if target.symlink && !policy.follow {
|
||||
continue
|
||||
}
|
||||
if target.dir {
|
||||
if !directoryDepthAllows(policy, frame.depth) {
|
||||
continue
|
||||
}
|
||||
if _, exists := activeDirectories[target.identity]; exists {
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")}
|
||||
}
|
||||
childMissing, childIsDirectory, childDiagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||
if childDiagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||
}
|
||||
if childMissing || !childIsDirectory {
|
||||
continue
|
||||
}
|
||||
childEntries, vanished, childDiagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
|
||||
if childDiagnostic != nil {
|
||||
return importSelection{}, []Diagnostic{*childDiagnostic}
|
||||
}
|
||||
if !vanished {
|
||||
if access.native == nil {
|
||||
candidate.accessPath = target.identity
|
||||
}
|
||||
activeDirectories[target.identity] = struct{}{}
|
||||
stack = append(stack, importDirectoryFrame{directory: candidate, depth: frame.depth + 1, entries: childEntries, identity: target.identity})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if target.identity != "" && access.native == nil {
|
||||
candidate.accessPath = target.identity
|
||||
} else {
|
||||
canonical, canonicalErr := access.canonical(accessPath)
|
||||
if canonicalErr != nil {
|
||||
if errors.Is(canonicalErr, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return importSelection{}, []Diagnostic{globFilesystemDiagnostic(canonicalErr, "directory search file could not be canonicalized", frame.directory.site.location)}
|
||||
}
|
||||
candidate.accessPath = canonical
|
||||
}
|
||||
if !matchesImportFilename(entry.Name(), policy.pattern) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenCandidates[candidate.path]; exists {
|
||||
continue
|
||||
}
|
||||
seenCandidates[candidate.path] = struct{}{}
|
||||
discovered = append(discovered, candidate)
|
||||
}
|
||||
}
|
||||
sort.Slice(discovered, func(left, right int) bool { return discovered[left].path < discovered[right].path })
|
||||
@@ -223,12 +170,76 @@ func selectDirectoryImportCandidates(access *filesystemAccess, basePath string,
|
||||
return importSelection{}, []Diagnostic{*importSelectionDiagnostic(spec.fieldSites["dir"], CodeImportMissing, "required directory import selected no files")}
|
||||
}
|
||||
selection := importSelection{candidates: selected}
|
||||
if len(selected) != 0 {
|
||||
selection.outcome = importSelectionHasCandidates
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func prepareImportDirectoryEntry(access *filesystemAccess, basePath string, frame importDirectoryFrame, entry fs.DirEntry, policy resolvedImportPolicy, limits importLimits, accounting *importAccounting, activeDirectories map[string]struct{}) (importDirectoryEntryResult, *Diagnostic) {
|
||||
accessPath := access.join(frame.directory.accessPath, entry.Name())
|
||||
lexicalPath := access.join(frame.directory.path, entry.Name())
|
||||
candidate := selectedImportCandidate{
|
||||
path: normalizedImportCandidatePath(access, lexicalPath), accessPath: accessPath,
|
||||
label: relativeImportLabel(access, basePath, lexicalPath), site: frame.directory.site,
|
||||
}
|
||||
if !policy.hidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
target, err := access.importEntryTarget(candidate.accessPath, entry, policy.follow)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
return importDirectoryEntryResult{}, importDirectoryTargetDiagnostic(frame.directory.site, err)
|
||||
}
|
||||
if target.symlink && !policy.follow {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
if target.dir {
|
||||
if !directoryDepthAllows(policy, frame.depth) {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
if _, exists := activeDirectories[target.identity]; exists {
|
||||
return importDirectoryEntryResult{}, importSelectionDiagnostic(frame.directory.site, CodeImportCycle, "directory traversal cycle detected")
|
||||
}
|
||||
missing, isDirectory, diagnostic := validateNamedImportTarget(access, candidate, false, true)
|
||||
if diagnostic != nil {
|
||||
return importDirectoryEntryResult{}, diagnostic
|
||||
}
|
||||
if missing || !isDirectory {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
entries, vanished, diagnostic := importDirectoryEntries(access, candidate.accessPath, candidate.site, limits, accounting)
|
||||
if diagnostic != nil {
|
||||
return importDirectoryEntryResult{}, diagnostic
|
||||
}
|
||||
if vanished {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
if access.native == nil {
|
||||
candidate.accessPath = target.identity
|
||||
}
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntryDirectory, frame: importDirectoryFrame{
|
||||
directory: candidate, depth: frame.depth + 1, entries: entries, identity: target.identity,
|
||||
}}, nil
|
||||
}
|
||||
if target.identity != "" && access.native == nil {
|
||||
candidate.accessPath = target.identity
|
||||
} else {
|
||||
canonical, err := access.canonical(accessPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
diagnostic := globFilesystemDiagnostic(err, "directory search file could not be canonicalized", frame.directory.site.location)
|
||||
return importDirectoryEntryResult{}, &diagnostic
|
||||
}
|
||||
candidate.accessPath = canonical
|
||||
}
|
||||
if !matchesImportFilename(entry.Name(), policy.pattern) {
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntrySkip}, nil
|
||||
}
|
||||
return importDirectoryEntryResult{kind: importDirectoryEntryFile, candidate: candidate}, nil
|
||||
}
|
||||
|
||||
type importDirectoryFrame struct {
|
||||
directory selectedImportCandidate
|
||||
depth int
|
||||
@@ -237,6 +248,20 @@ type importDirectoryFrame struct {
|
||||
identity string
|
||||
}
|
||||
|
||||
type importDirectoryEntryKind uint8
|
||||
|
||||
const (
|
||||
importDirectoryEntrySkip importDirectoryEntryKind = iota
|
||||
importDirectoryEntryFile
|
||||
importDirectoryEntryDirectory
|
||||
)
|
||||
|
||||
type importDirectoryEntryResult struct {
|
||||
kind importDirectoryEntryKind
|
||||
candidate selectedImportCandidate
|
||||
frame importDirectoryFrame
|
||||
}
|
||||
|
||||
func importDirectoryTargetDiagnostic(site importFieldSite, err error) *Diagnostic {
|
||||
if isImportLinkCycle(err) {
|
||||
return importSelectionDiagnostic(site, CodeImportCycle, "directory traversal cycle detected")
|
||||
@@ -267,7 +292,7 @@ func importDirectoryEntries(access *filesystemAccess, directory string, site imp
|
||||
if errors.Is(err, errImportDirectoryEntryLimit) {
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestSelectNamedImportsHandlesPartialAndEmptySelections(t *testing.T) {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestSelectNamedImportsChecksRequiredTargetsBeforeExclusions(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
@@ -118,12 +118,12 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], importDefaults{})
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], ImportDefaults{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if filesystem.openCalls != 0 || filesystem.statCalls == 0 {
|
||||
@@ -135,7 +135,7 @@ func TestSelectNamedImportsDoesNotOpenExcludedContentOrApplySearchControls(t *te
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
hidden := false
|
||||
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], importDefaults{hidden: &hidden})
|
||||
privatePolicy, policyDiagnostics := resolveImportPolicy(private[0], ImportDefaults{Hidden: &hidden})
|
||||
if len(policyDiagnostics) != 0 || !privatePolicy.hidden {
|
||||
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 {
|
||||
t.Fatalf("selection, diagnostics = %#v, %#v", selection, diagnostics)
|
||||
}
|
||||
if strings.Join(filesystem.statPaths, ",") != "a.yaml,a.yaml,z.yaml,z.yaml" {
|
||||
t.Fatalf("target stat order = %#v", filesystem.statPaths)
|
||||
if selection.candidates[0].path != "a.yaml" || selection.candidates[1].path != "z.yaml" {
|
||||
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
|
||||
openCalls int
|
||||
statCalls int
|
||||
statPaths []string
|
||||
}
|
||||
|
||||
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) {
|
||||
filesystem.statCalls++
|
||||
filesystem.statPaths = append(filesystem.statPaths, name)
|
||||
if err := filesystem.statErrors[name]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -35,23 +35,13 @@ type importDeclarationSpec struct {
|
||||
pattern []string
|
||||
patternSet bool
|
||||
exclude []string
|
||||
excludeSet bool
|
||||
depth int
|
||||
depthSet bool
|
||||
destination Path
|
||||
placement placementMode
|
||||
placement PlacementMode
|
||||
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
|
||||
// boundary. Load options snapshot this value before source acquisition.
|
||||
type ImportDefaults struct {
|
||||
@@ -66,26 +56,6 @@ type ImportDefaults struct {
|
||||
// Pattern filters directory-search filenames; nil and empty differ.
|
||||
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 {
|
||||
required bool
|
||||
follow bool
|
||||
@@ -96,16 +66,16 @@ type resolvedImportPolicy struct {
|
||||
depthSet bool
|
||||
exclude []string
|
||||
destination Path
|
||||
placement placementMode
|
||||
placement PlacementMode
|
||||
}
|
||||
|
||||
func snapshotImportDefaults(value importDefaults) importDefaults {
|
||||
return importDefaults{
|
||||
required: cloneBool(value.required),
|
||||
follow: cloneBool(value.follow),
|
||||
hidden: cloneBool(value.hidden),
|
||||
recursive: cloneBool(value.recursive),
|
||||
pattern: cloneStrings(value.pattern),
|
||||
func snapshotImportDefaults(value ImportDefaults) ImportDefaults {
|
||||
return ImportDefaults{
|
||||
Required: cloneBool(value.Required),
|
||||
Follow: cloneBool(value.Follow),
|
||||
Hidden: cloneBool(value.Hidden),
|
||||
Recursive: cloneBool(value.Recursive),
|
||||
Pattern: cloneStrings(value.Pattern),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +94,8 @@ func cloneStrings(values []string) []string {
|
||||
return append(make([]string, 0, len(values)), values...)
|
||||
}
|
||||
|
||||
func validateImportDefaults(value importDefaults) []Diagnostic {
|
||||
for _, pattern := range value.pattern {
|
||||
func validateImportDefaults(value ImportDefaults) []Diagnostic {
|
||||
for _, pattern := range value.Pattern {
|
||||
if _, err := path.Match(pattern, ""); err != nil {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "import default pattern is invalid")}
|
||||
}
|
||||
@@ -133,10 +103,9 @@ func validateImportDefaults(value importDefaults) []Diagnostic {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (resolvedImportPolicy, []Diagnostic) {
|
||||
defaults = snapshotImportDefaults(defaults)
|
||||
func resolveImportPolicy(spec importDeclarationSpec, defaults ImportDefaults) (resolvedImportPolicy, []Diagnostic) {
|
||||
policy := resolvedImportPolicy{
|
||||
required: boolOr(spec.required, defaults.required, false),
|
||||
required: boolOr(spec.required, defaults.Required, false),
|
||||
follow: true,
|
||||
hidden: true,
|
||||
recursive: false,
|
||||
@@ -147,15 +116,15 @@ func resolveImportPolicy(spec importDeclarationSpec, defaults importDefaults) (r
|
||||
exclude: cloneStrings(spec.exclude),
|
||||
}
|
||||
if spec.selector == importSelectorDirectory || spec.selector == importSelectorGlob {
|
||||
policy.follow = boolOr(spec.follow, defaults.follow, true)
|
||||
policy.hidden = boolOr(spec.hidden, defaults.hidden, true)
|
||||
policy.follow = boolOr(spec.follow, defaults.Follow, true)
|
||||
policy.hidden = boolOr(spec.hidden, defaults.Hidden, true)
|
||||
}
|
||||
if spec.selector == importSelectorDirectory {
|
||||
policy.recursive = boolOr(spec.recursive, defaults.recursive, false)
|
||||
policy.recursive = boolOr(spec.recursive, defaults.Recursive, false)
|
||||
if spec.patternSet {
|
||||
policy.pattern = cloneStrings(spec.pattern)
|
||||
} else {
|
||||
policy.pattern = cloneStrings(defaults.pattern)
|
||||
policy.pattern = cloneStrings(defaults.Pattern)
|
||||
}
|
||||
}
|
||||
if spec.depthSet && !policy.recursive {
|
||||
@@ -180,26 +149,6 @@ func boolOr(explicit, application *bool, fallback bool) bool {
|
||||
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) {
|
||||
if node == nil || node.Kind != document.Sequence {
|
||||
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{
|
||||
path: declarationPath.clone(),
|
||||
location: publicLocation(node.Location),
|
||||
placement: placementModeMapping,
|
||||
placement: PlacementModeMapping,
|
||||
fieldSites: make(map[string]importFieldSite, len(node.Mapping)),
|
||||
}
|
||||
selectors := 0
|
||||
@@ -251,40 +200,17 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
||||
}
|
||||
switch entry.Key {
|
||||
case "required", "recursive", "follow", "hidden":
|
||||
if entry.Value.Kind != document.Boolean {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, entry.Key+" must be a boolean", entry.Value)}
|
||||
}
|
||||
value := entry.Value.Text == "true"
|
||||
switch entry.Key {
|
||||
case "required":
|
||||
spec.required = &value
|
||||
case "recursive":
|
||||
spec.recursive = &value
|
||||
case "follow":
|
||||
spec.follow = &value
|
||||
case "hidden":
|
||||
spec.hidden = &value
|
||||
if diagnostic := parseImportBooleanAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
|
||||
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
case "pattern":
|
||||
values, origins, ok, badIndex := parseStringSet(entry.Value)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "pattern", badIndex)}
|
||||
if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
|
||||
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if badIndex, ok := invalidWildcard(values); ok {
|
||||
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "pattern contains an invalid wildcard expression", badNode)}
|
||||
}
|
||||
spec.pattern, spec.patternSet = values, true
|
||||
case "exclude":
|
||||
values, origins, ok, badIndex := parseStringSet(entry.Value)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(entry.Value, fieldPath, "exclude", badIndex)}
|
||||
if diagnostic := parseImportWildcardAssignment(&spec, entry.Key, entry.Value, fieldPath); diagnostic != nil {
|
||||
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if badIndex, ok := invalidWildcard(values); ok {
|
||||
badPath, badNode := listMemberSite(entry.Value, fieldPath, origins[badIndex])
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, "exclude contains an invalid wildcard expression", badNode)}
|
||||
}
|
||||
spec.exclude, spec.excludeSet = values, true
|
||||
case "depth":
|
||||
depth, ok := parseImportDepth(entry.Value)
|
||||
if !ok {
|
||||
@@ -298,17 +224,11 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
||||
}
|
||||
spec.destination = destination
|
||||
case "placement":
|
||||
if entry.Value.Kind != document.String {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
||||
}
|
||||
switch entry.Value.Text {
|
||||
case "mapping":
|
||||
spec.placement = placementModeMapping
|
||||
case "sequence-item":
|
||||
spec.placement = placementModeSequenceItem
|
||||
default:
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", entry.Value)}
|
||||
placement, diagnostic := parseImportPlacement(entry.Value, fieldPath)
|
||||
if diagnostic != nil {
|
||||
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
spec.placement = placement
|
||||
default:
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||
}
|
||||
@@ -316,33 +236,98 @@ func parseImportDeclarationSpec(node *document.Node, declarationPath Path) (impo
|
||||
|
||||
selectorPath := declarationPath.Key(selectorEntry.Key)
|
||||
spec.fieldSites[selectorEntry.Key] = importFieldSite{path: selectorPath.clone(), location: publicLocation(selectorEntry.Value.Location)}
|
||||
if diagnostic := parseImportSelectorValue(&spec, selectorEntry.Key, selectorEntry.Value, selectorPath); diagnostic != nil {
|
||||
return importDeclarationSpec{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func parseImportBooleanAssignment(spec *importDeclarationSpec, field string, node *document.Node, fieldPath Path) *Diagnostic {
|
||||
if node.Kind != document.Boolean {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, field+" must be a boolean", node)
|
||||
return &diagnostic
|
||||
}
|
||||
value := node.Text == "true"
|
||||
switch field {
|
||||
case "required":
|
||||
spec.required = &value
|
||||
case "recursive":
|
||||
spec.recursive = &value
|
||||
case "follow":
|
||||
spec.follow = &value
|
||||
case "hidden":
|
||||
spec.hidden = &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseImportWildcardAssignment(spec *importDeclarationSpec, field string, node *document.Node, fieldPath Path) *Diagnostic {
|
||||
values, origins, ok, badIndex := parseStringSet(node)
|
||||
if !ok {
|
||||
diagnostic := stringSetDiagnostic(node, fieldPath, field, badIndex)
|
||||
return &diagnostic
|
||||
}
|
||||
if badIndex, ok := invalidWildcard(values); ok {
|
||||
badPath, badNode := listMemberSite(node, fieldPath, origins[badIndex])
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, badPath, field+" contains an invalid wildcard expression", badNode)
|
||||
return &diagnostic
|
||||
}
|
||||
switch field {
|
||||
case "pattern":
|
||||
spec.pattern, spec.patternSet = values, true
|
||||
case "exclude":
|
||||
spec.exclude = values
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseImportPlacement(node *document.Node, fieldPath Path) (PlacementMode, *Diagnostic) {
|
||||
if node.Kind != document.String {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
|
||||
return PlacementModeMapping, &diagnostic
|
||||
}
|
||||
switch node.Text {
|
||||
case "mapping":
|
||||
return PlacementModeMapping, nil
|
||||
case "sequence-item":
|
||||
return PlacementModeSequenceItem, nil
|
||||
default:
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, fieldPath, "placement must be mapping or sequence-item", node)
|
||||
return PlacementModeMapping, &diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
func parseImportSelectorValue(spec *importDeclarationSpec, field string, node *document.Node, selectorPath Path) *Diagnostic {
|
||||
switch spec.selector {
|
||||
case importSelectorPath, importSelectorDirectory:
|
||||
values, origins, ok, badIndex := parseStringSet(selectorEntry.Value)
|
||||
values, origins, ok, badIndex := parseStringSet(node)
|
||||
if !ok {
|
||||
name := selectorEntry.Key
|
||||
return importDeclarationSpec{}, []Diagnostic{stringSetDiagnostic(selectorEntry.Value, selectorPath, name, badIndex)}
|
||||
diagnostic := stringSetDiagnostic(node, selectorPath, field, badIndex)
|
||||
return &diagnostic
|
||||
}
|
||||
for index, value := range values {
|
||||
if !validImportPath(value) {
|
||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, badPath, selectorEntry.Key+" contains an invalid source path", badNode)}
|
||||
badPath, badNode := listMemberSite(node, selectorPath, origins[index])
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, badPath, field+" contains an invalid source path", badNode)
|
||||
return &diagnostic
|
||||
}
|
||||
badPath, badNode := listMemberSite(selectorEntry.Value, selectorPath, origins[index])
|
||||
badPath, badNode := listMemberSite(node, selectorPath, origins[index])
|
||||
spec.selectorSites = append(spec.selectorSites, importFieldSite{path: badPath.clone(), location: publicLocation(badNode.Location)})
|
||||
}
|
||||
spec.paths = values
|
||||
case importSelectorGlob:
|
||||
if selectorEntry.Value.Kind != document.String || !validImportPath(selectorEntry.Value.Text) {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", selectorEntry.Value)}
|
||||
if node.Kind != document.String || !validImportPath(node.Text) {
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob must be a valid nonempty path pattern string", node)
|
||||
return &diagnostic
|
||||
}
|
||||
segments, ok := validGlobPattern(selectorEntry.Value.Text)
|
||||
segments, ok := validGlobPattern(node.Text)
|
||||
if !ok {
|
||||
return importDeclarationSpec{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", selectorEntry.Value)}
|
||||
diagnostic := nodeImportDiagnostic(CodeImportInvalid, selectorPath, "glob pattern is invalid", node)
|
||||
return &diagnostic
|
||||
}
|
||||
spec.glob, spec.globSegments = selectorEntry.Value.Text, segments
|
||||
spec.glob, spec.globSegments = node.Text, segments
|
||||
}
|
||||
return spec, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectorForField(field string) importSelector {
|
||||
|
||||
@@ -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" {
|
||||
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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
third := specs[2]
|
||||
@@ -123,13 +123,13 @@ func TestParseImportDeclarationSpecValidatesDepthSyntaxAndPolicy(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
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" {
|
||||
t.Fatalf("policy diagnostics = %#v, want depth applicability error", diagnostics)
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("application-default policy, diagnostics = %#v, %#v", policy, diagnostics)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
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 {
|
||||
t.Fatalf("library defaults = %#v, diagnostics = %#v", omitted, diagnostics)
|
||||
}
|
||||
@@ -159,12 +159,12 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
appRequired, appRecursive, appFollow, appHidden := true, true, true, true
|
||||
defaults := importDefaults{
|
||||
required: &appRequired,
|
||||
recursive: &appRecursive,
|
||||
follow: &appFollow,
|
||||
hidden: &appHidden,
|
||||
pattern: []string{"*.yaml"},
|
||||
defaults := ImportDefaults{
|
||||
Required: &appRequired,
|
||||
Recursive: &appRecursive,
|
||||
Follow: &appFollow,
|
||||
Hidden: &appHidden,
|
||||
Pattern: []string{"*.yaml"},
|
||||
}
|
||||
policy, diagnostics := resolveImportPolicy(specs[0], defaults)
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -188,16 +188,24 @@ func TestResolveImportPolicyAppliesDefaultsIndependently(t *testing.T) {
|
||||
}
|
||||
|
||||
snapshot := snapshotImportDefaults(defaults)
|
||||
*defaults.required = false
|
||||
defaults.pattern[0] = "*.toml"
|
||||
if snapshot.required == nil || !*snapshot.required || snapshot.pattern[0] != "*.yaml" {
|
||||
*defaults.Required = false
|
||||
*defaults.Follow = false
|
||||
*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)
|
||||
}
|
||||
empty := snapshotImportDefaults(importDefaults{pattern: []string{}})
|
||||
if empty.pattern == nil || len(empty.pattern) != 0 {
|
||||
omittedSnapshot := snapshotImportDefaults(ImportDefaults{})
|
||||
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)
|
||||
}
|
||||
if snapshotImportDefaults(importDefaults{}).pattern != nil {
|
||||
if snapshotImportDefaults(ImportDefaults{}).Pattern != nil {
|
||||
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")}
|
||||
_, err := Load(Reader("invalid-default-pattern.yml", reader), testSecretBinding(), Options[struct{}]{
|
||||
importDefaults: importDefaults{pattern: []string{"["}},
|
||||
ImportDefaults: ImportDefaults{Pattern: []string{"["}},
|
||||
})
|
||||
var loadErr *LoadError
|
||||
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 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
index := documentEntryIndex(parsed.document.Mapping, directiveKey)
|
||||
if index < 0 {
|
||||
t.Fatal("missing root directive in test fixture")
|
||||
extracted, diagnostics := extractRootDirectives(parsed.document, rootDirectiveContextSource)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
return parseImportSpecificationDirective(parsed.document.Mapping[index].Value, Keys(directiveKey))
|
||||
return extracted.imports, nil
|
||||
}
|
||||
|
||||
type countingImportSpecReader struct {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,14 +113,22 @@ tasks: [{id: z-parent}]
|
||||
aIndependentPath := writeImportFile(t, directory, "a-independent.yml", "tasks: [{id: a-independent}]\n")
|
||||
bDependencyPath := writeImportFile(t, directory, "b-dependency.yml", "tasks: [{id: b-dependency}]\n")
|
||||
|
||||
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
|
||||
Imports: true,
|
||||
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
|
||||
})
|
||||
sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}})
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||
directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{
|
||||
sequencePolicy: sequencePolicy,
|
||||
importLimits: defaultImportLimits(),
|
||||
}, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
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 {
|
||||
t.Fatalf("tasks document = %#v, %v", tasks, ok)
|
||||
}
|
||||
@@ -153,7 +161,12 @@ func TestAcquiredContextsUseSelectedFileWhenSourceRecordsAreReordered(t *testing
|
||||
{ID: "source-2", Path: canonicalTestPath(t, importedPath), Kind: SourceKindFile},
|
||||
{ID: "source-1", Path: canonicalTestPath(t, mainPath), Kind: SourceKindFile},
|
||||
}
|
||||
contexts, diagnostic := contextsForAcquiredSources(File(mainPath), nil, nil, sources)
|
||||
mainInfo := &SourceInfo{Path: canonicalTestPath(t, mainPath)}
|
||||
resolved, diagnostic := resolveFilesystemContext(File(mainPath), nil, parsedSource{source: mainInfo}, nil)
|
||||
if diagnostic != nil {
|
||||
t.Fatalf("resolve main context: %#v", diagnostic)
|
||||
}
|
||||
contexts, diagnostic := contextsForResolvedSources(&resolved, sources)
|
||||
if diagnostic != nil {
|
||||
t.Fatalf("source contexts: %#v", diagnostic)
|
||||
}
|
||||
@@ -400,7 +413,7 @@ label: helper
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource(), Imports: true}, testSecretBinding(),
|
||||
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 {
|
||||
@@ -490,7 +503,7 @@ func TestLoadItemGlobKeepsDefaultModeIndependentAndOptionalMissesAbsent(t *testi
|
||||
t.Fatalf("config = %#v", result.Config)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -508,7 +521,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
|
||||
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")
|
||||
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 {
|
||||
t.Fatalf("load main item: %v", err)
|
||||
@@ -517,7 +530,7 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) {
|
||||
t.Fatalf("main item = %#v", result.Config.Items)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if sources[1].Path != canonicalTestPath(t, helperPath) {
|
||||
@@ -536,7 +549,7 @@ func TestProjectAcquiredDestinationsUsesMappedSequenceItemIndex(t *testing.T) {
|
||||
items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}},
|
||||
}}}
|
||||
scope := acquisitionScope{contributions: []acquiredContribution{{
|
||||
source: SourceInfo{ID: "outer-source", placementMode: placementModeSequenceItem},
|
||||
source: SourceInfo{ID: "outer-source", placementMode: PlacementModeSequenceItem},
|
||||
destination: Keys("records"), nested: child,
|
||||
}}}
|
||||
sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}}
|
||||
@@ -617,9 +630,10 @@ id: item
|
||||
`)
|
||||
helperPath := writeImportFile(t, directory, "helper.yml", "value: acquired\n")
|
||||
accounting := &importAccounting{}
|
||||
input, diagnostics := assembleBaseInputTracked(File(mainPath), Options[struct{}]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: directory}, deferAssembly: true,
|
||||
}, Path{}, accounting, nil, nil)
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true, filesystem: &FilesystemContext{Root: directory}},
|
||||
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{importLimits: defaultImportLimits()}, accounting)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("acquire input: %#v", diagnostics)
|
||||
}
|
||||
@@ -674,12 +688,20 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
|
||||
`)
|
||||
writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: imported\n")
|
||||
writeImportFile(t, directory, "helper.yml", "id: helper-id\n")
|
||||
input, diagnostics := assembleBaseInput(File(mainPath), Options[config]{Imports: true})
|
||||
sequencePolicy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}})
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||
directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
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")
|
||||
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 {
|
||||
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
||||
}
|
||||
@@ -738,7 +760,7 @@ func TestParseImportPlacementDefaultsPerDeclaration(t *testing.T) {
|
||||
- glob: records/*.yml
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -862,7 +884,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||
writeImportFile(t, directory, "child.yml", "configkit: {imports: [{path: absent.yml}]}\n")
|
||||
_, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Imports: true, importDefaults: importDefaults{required: &required},
|
||||
Imports: true, ImportDefaults: ImportDefaults{Required: &required},
|
||||
})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||
@@ -874,7 +896,7 @@ func TestImportDefaultsReachOrdinaryAndGroupedDeclarations(t *testing.T) {
|
||||
_, err = loadGroupedWithSecretLookup(loadRequest[importTestConfig]{
|
||||
main: loadInput{source: File(basePath)},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: File(underlayPath), imports: true}}}},
|
||||
importDefaults: importDefaults{required: &required},
|
||||
importDefaults: ImportDefaults{Required: &required},
|
||||
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||
t.Fatalf("grouped underlay required default error = %#v", err)
|
||||
@@ -951,7 +973,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
|
||||
itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n")
|
||||
helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n")
|
||||
|
||||
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||
directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("assemble input: %#v", diagnostics)
|
||||
}
|
||||
@@ -983,10 +1008,10 @@ func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
itemScope := item.nested.acquisition[0]
|
||||
itemScope := item.pendingItem
|
||||
if itemScope.owner != item.identity || len(itemScope.contributions) != 2 {
|
||||
t.Fatalf("nested item scope = %#v", itemScope)
|
||||
}
|
||||
@@ -1082,7 +1107,7 @@ id: item
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: helper.yml}]}\nid: main\n")
|
||||
writeImportFile(t, directory, "helper.yml", "id: helper\n")
|
||||
options := Options[importTestConfig]{
|
||||
Imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem,
|
||||
Imports: true, Destination: Keys("items"), PlacementMode: PlacementModeSequenceItem,
|
||||
}
|
||||
loadError := requireLoadCode(t, File(mainPath), options, CodeComposeConflict)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
@@ -1092,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) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
@@ -1104,12 +1193,19 @@ func TestItemHelperScopeScheduleInterleavesUnequalBranches(t *testing.T) {
|
||||
writeImportFile(t, directory, "b-parent.yml", "configkit: {imports: [{path: a-dependency.yml}]}\nb: true\n")
|
||||
writeImportFile(t, directory, "a-dependency.yml", "a: true\n")
|
||||
writeImportFile(t, directory, "c-independent.yml", "c: true\n")
|
||||
input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
input, diagnostics := testAcquireInput(inputAcquisitionRequest{
|
||||
input: loadInput{source: File(mainPath), imports: true}, role: sourceVersionMain,
|
||||
directiveContext: rootDirectiveContextImportsOnly,
|
||||
}, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
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
|
||||
for _, contribution := range input.composition.acquisition[0].contributions {
|
||||
for _, contribution := range composition.acquisition[0].contributions {
|
||||
if contribution.role == importRoleItem {
|
||||
nested = contribution.nested
|
||||
break
|
||||
@@ -1268,7 +1364,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, test.limits)
|
||||
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||
_, _, diagnostics := testAcquireImportWithLimits(request, test.limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("budget diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -1278,7 +1375,8 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||
main = parseImportMain(t, mainPath)
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||
_, _, diagnostics := testAcquireImportWithLimits(request, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("depth diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -1312,15 +1410,17 @@ func TestImportDepthBudgetCountsSelectedEdges(t *testing.T) {
|
||||
writeImportFile(t, directory, "parts/next.yml", "next: true\n")
|
||||
limits := defaultImportLimits()
|
||||
limits.depth = 1
|
||||
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, parseImportMain(t, mainPath), nil, limits)
|
||||
main := parseImportMain(t, mainPath)
|
||||
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||
acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||
if test.code != "" {
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.code {
|
||||
t.Fatalf("diagnostics = %#v, want %s", diagnostics, test.code)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("depth-boundary composition: sources=%#v diagnostics=%#v", sources, diagnostics)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
|
||||
t.Fatalf("depth-boundary acquisition: sources=%#v diagnostics=%#v", sources, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1342,9 +1442,10 @@ func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||
bytes: main.bytes + int64(len(data)),
|
||||
nodes: countInputNodes(main.document) + countInputNodes(imported.document),
|
||||
}
|
||||
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
||||
request := testImportRequest(File(mainPath), nil, main, Path{}, nil, nil)
|
||||
acquired, sources, diagnostics := testAcquireImportWithLimits(request, limits)
|
||||
if len(diagnostics) != 0 || acquired == nil || len(acquired.acquisition) == 0 || len(sources) != 2 {
|
||||
t.Fatalf("exact-limit acquisition = (%#v, %#v, %#v)", acquired, sources, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,6 +1502,50 @@ func parseImportMain(t *testing.T, filename string) parsedSource {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func testImportRequest(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, processing *sourceVersionProcessingResult) importAcquisitionRequest {
|
||||
return importAcquisitionRequest{
|
||||
input: inputAcquisitionRequest{
|
||||
input: loadInput{
|
||||
source: source, imports: true, filesystem: context,
|
||||
destination: destination.clone(), placementMode: main.source.placementMode,
|
||||
},
|
||||
role: sourceVersionMain, directiveContext: rootDirectiveContextImportsOnly,
|
||||
protectionRule: rule,
|
||||
},
|
||||
main: main, processing: processing,
|
||||
}
|
||||
}
|
||||
|
||||
func testAcquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||
if accounting == nil {
|
||||
accounting = &importAccounting{}
|
||||
}
|
||||
state := &acquisitionState{accounting: accounting}
|
||||
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)}
|
||||
return acquireInput(request, policy, state, tier)
|
||||
}
|
||||
|
||||
func testAcquireImport(request importAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
composition, sources, _, diagnostics := acquireImportedInput(request, &policy, state, tier)
|
||||
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) {
|
||||
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: &importAccounting{}}, &tierAcquisitionState{})
|
||||
}
|
||||
|
||||
func testAcquireImportWithAccounting(request importAcquisitionRequest, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
return testAcquireImport(request, acquisitionPolicy{importLimits: limits}, &acquisitionState{accounting: accounting}, &tierAcquisitionState{})
|
||||
}
|
||||
|
||||
func parseImportSpecificationsYAML(t *testing.T, input string) []importDeclarationSpec {
|
||||
t.Helper()
|
||||
declarations, diagnostics := parseImportSpecificationYAML(input)
|
||||
|
||||
@@ -129,15 +129,11 @@ func projectInheritancePolicyTemplates(templates []collectionPolicyScope, prefix
|
||||
return projected
|
||||
}
|
||||
|
||||
// A replacement retains the displaced source/derivation record while the
|
||||
// effective origin belongs to the child. Value trees are borrowed immutable
|
||||
// inputs or detached templates and are budgeted with retained history later.
|
||||
// A replacement retains the before and after values for history accounting.
|
||||
// The trees are borrowed immutable inputs or detached templates.
|
||||
type inheritanceReplacement struct {
|
||||
path Path
|
||||
before *document.Node
|
||||
after *document.Node
|
||||
previous []displacedDefinition
|
||||
effective Origin
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func inheritanceDirectiveLocation(node *document.Node) Location {
|
||||
entry, _ := inheritanceDirective(node)
|
||||
return publicLocation(entry.Value.Location)
|
||||
}
|
||||
|
||||
func withoutInheritanceDirective(node *document.Node) *document.Node {
|
||||
payload := *node
|
||||
payload.Mapping = make([]document.Entry, 0, len(node.Mapping))
|
||||
|
||||
@@ -228,19 +228,17 @@ func applyInheritanceSequencePolicy(
|
||||
}
|
||||
|
||||
func applyInheritanceSequenceOrigins(
|
||||
result *inheritanceScope,
|
||||
origins, incomingOrigins map[string]Origin,
|
||||
path Path,
|
||||
lower, higher *document.Node,
|
||||
combined sequenceCombinationResult,
|
||||
operation sequenceMode,
|
||||
) {
|
||||
) []inheritanceReplacement {
|
||||
var replacements []inheritanceReplacement
|
||||
if operation == sequenceModeReplace {
|
||||
if lower != nil {
|
||||
displaced := displacedOrigins(lower, origins, path)
|
||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||
path: path.clone(), before: lower, after: higher,
|
||||
previous: displaced, effective: cloneOrigin(incomingOrigins[path.String()]),
|
||||
replacements = append(replacements, inheritanceReplacement{
|
||||
before: lower, after: higher,
|
||||
})
|
||||
deleteOriginSubtree(origins, path, lower)
|
||||
}
|
||||
@@ -253,7 +251,7 @@ func applyInheritanceSequenceOrigins(
|
||||
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
|
||||
}
|
||||
}
|
||||
return
|
||||
return replacements
|
||||
}
|
||||
|
||||
mergeInheritanceOrigin(origins, path, incomingOrigins[path.String()])
|
||||
@@ -265,12 +263,10 @@ func applyInheritanceSequenceOrigins(
|
||||
lowerPath := path.Index(replacement.lowerIndex)
|
||||
higherPath := path.Index(replacement.higherIndex)
|
||||
outputPath := path.Index(replacement.outputIndex)
|
||||
previous := displacedOrigins(lower.Sequence[replacement.lowerIndex], origins, lowerPath)
|
||||
deleteOriginSubtree(origins, lowerPath, lower.Sequence[replacement.lowerIndex])
|
||||
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[replacement.higherIndex], higherPath, outputPath)
|
||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||
path: outputPath, before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
|
||||
previous: previous, effective: cloneOrigin(incomingOrigins[higherPath.String()]),
|
||||
replacements = append(replacements, inheritanceReplacement{
|
||||
before: lower.Sequence[replacement.lowerIndex], after: higher.Sequence[replacement.higherIndex],
|
||||
})
|
||||
}
|
||||
for _, projection := range combined.higherItems {
|
||||
@@ -278,6 +274,7 @@ func applyInheritanceSequenceOrigins(
|
||||
copyInheritanceSubtreeAt(origins, incomingOrigins, higher.Sequence[projection.inputIndex], path.Index(projection.inputIndex), path.Index(projection.outputIndex))
|
||||
}
|
||||
}
|
||||
return replacements
|
||||
}
|
||||
|
||||
func validateRegisteredInheritanceSequences(root *document.Node, rootPath Path, applicationPolicy sequencePolicy) []Diagnostic {
|
||||
|
||||
117
internal/engine/inheritance_combine.go
Normal file
117
internal/engine/inheritance_combine.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type inheritanceTemplateResult struct {
|
||||
value *document.Node
|
||||
origins map[string]Origin
|
||||
collectionPolicies collectionPolicyScope
|
||||
policyTemplates []collectionPolicyScope
|
||||
controls []inheritanceControl
|
||||
transfers []inheritanceTransfer
|
||||
replacements []inheritanceReplacement
|
||||
operations []inheritanceOperation
|
||||
}
|
||||
|
||||
func combineInheritanceTemplate(
|
||||
template inheritanceExecutionTemplate,
|
||||
child preparedInheritanceChild,
|
||||
parent *preparedInheritanceParent,
|
||||
parentOrigins map[string]Origin,
|
||||
applicationPolicy sequencePolicy,
|
||||
) (inheritanceTemplateResult, []Diagnostic) {
|
||||
result := inheritanceTemplateResult{}
|
||||
if child.control != nil {
|
||||
result.controls = append(result.controls, *child.control)
|
||||
result.operations = append(result.operations, inheritanceOperation{
|
||||
control: child.control, controlOrigin: child.controlOrigin,
|
||||
})
|
||||
}
|
||||
if parent == nil {
|
||||
result.value = document.Clone(child.payload)
|
||||
result.origins = child.origins
|
||||
result.collectionPolicies = cloneCollectionPolicyScopeForInheritance(child.policyTemplates)
|
||||
result.policyTemplates = []collectionPolicyScope{child.policyTemplates}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
mergedPolicyScope, diagnostics := mergeInheritanceCollectionPolicies(
|
||||
parent.parentPolicyScope, parent.childPolicyScope,
|
||||
template.path, parent.payload, child.payload,
|
||||
applicationPolicy, parent.replaced,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return inheritanceTemplateResult{}, diagnostics
|
||||
}
|
||||
mergedOrigins := transferredInheritanceOrigins(parentOrigins, parent.transfer.from, template.path, parent.transfer)
|
||||
mergeInheritanceOrigin(mergedOrigins, template.path, child.origins[template.path.String()])
|
||||
var sequenceContributions []sequenceHistoryContribution
|
||||
var combinationDiagnostics []Diagnostic
|
||||
mergeRecursiveMappings(parent.payload, child.payload, template.path, new(mappingIndex), recursiveMergeHooks{
|
||||
added: func(childPath Path, incoming *document.Node) {
|
||||
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
|
||||
},
|
||||
combined: func(childPath Path, _ *document.Node) {
|
||||
mergeInheritanceOrigin(mergedOrigins, childPath, child.origins[childPath.String()])
|
||||
},
|
||||
replaced: func(childPath Path, existing, incoming *document.Node) {
|
||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||
before: existing, after: incoming,
|
||||
})
|
||||
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
||||
copyInheritanceSubtree(mergedOrigins, child.origins, incoming, childPath)
|
||||
},
|
||||
merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) {
|
||||
resolution, _, _, active, issues := inheritanceCollectionResolution(
|
||||
childPath, parent.parentPolicyScope, parent.childPolicyScope, applicationPolicy, incoming,
|
||||
)
|
||||
if len(issues) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||
return existing, false
|
||||
}
|
||||
if !active {
|
||||
return nil, false
|
||||
}
|
||||
lowerValue := existing
|
||||
combined, problems := combineSequence(lowerValue, incoming, resolution, sequenceCombinationContext{destination: childPath})
|
||||
if len(problems) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, problems...)
|
||||
return existing, false
|
||||
}
|
||||
policy := applyInheritanceSequencePolicy(
|
||||
childPath, combined, resolution, parent.parentPolicyScope, parent.childPolicyScope,
|
||||
)
|
||||
mergedPolicyScope.set(childPath, policy)
|
||||
result.replacements = append(result.replacements, applyInheritanceSequenceOrigins(
|
||||
mergedOrigins, child.origins, childPath, lowerValue, incoming, combined, resolution.operation,
|
||||
)...)
|
||||
sequenceContributions = append(sequenceContributions, sequenceHistoryContributionForCombination(
|
||||
childPath, resolution.operation, incoming, combined,
|
||||
))
|
||||
return combined.value, true
|
||||
},
|
||||
})
|
||||
preserveInheritanceIdentityFields(parent.payload, child.payload, template.identityFields)
|
||||
if len(combinationDiagnostics) != 0 {
|
||||
return inheritanceTemplateResult{}, canonicalDiagnostics(combinationDiagnostics)
|
||||
}
|
||||
if problems := validateRegisteredInheritanceSequences(parent.payload, template.path, applicationPolicy); len(problems) != 0 {
|
||||
return inheritanceTemplateResult{}, problems
|
||||
}
|
||||
parent.payload.Location = template.entry.Value.Location
|
||||
result.value = parent.payload
|
||||
result.origins = mergedOrigins
|
||||
result.collectionPolicies = inheritancePolicyScopeAtPath(mergedPolicyScope, template.path)
|
||||
result.policyTemplates = parent.effectivePolicyTemplates
|
||||
result.transfers = append(result.transfers, parent.transfer)
|
||||
result.operations = append(result.operations, inheritanceOperation{
|
||||
transfer: &parent.transfer, parent: parent.historyParent, effective: parent.payload,
|
||||
parentOrigins: parentOrigins, effectiveOrigins: mergedOrigins,
|
||||
parentPolicies: cloneInheritancePolicyTemplates(parent.policyTemplates),
|
||||
templatePolicies: cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
|
||||
effectivePolicies: cloneInheritancePolicyTemplates(parent.effectivePolicyTemplates),
|
||||
sequences: cloneSequenceHistoryContributions(sequenceContributions),
|
||||
identityFields: cloneStrings(template.identityFields),
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
55
internal/engine/inheritance_finalize.go
Normal file
55
internal/engine/inheritance_finalize.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
func chargeInheritanceTemplateHistory(counter *retainedNodeCounter, result inheritanceTemplateResult, template inheritanceExecutionTemplate) *Diagnostic {
|
||||
for _, operation := range result.operations {
|
||||
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
||||
diagnostic := inheritanceHistoryBudgetDiagnostic(template.path, template.entry.Value.Location)
|
||||
return &diagnostic
|
||||
}
|
||||
}
|
||||
for _, replacement := range result.replacements {
|
||||
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
||||
diagnostic := inheritanceHistoryBudgetDiagnostic(template.path, template.entry.Value.Location)
|
||||
return &diagnostic
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeInheritanceScope(
|
||||
adapter inheritanceScopeAdapter,
|
||||
result *inheritanceScope,
|
||||
templates []inheritanceExecutionTemplate,
|
||||
resolved []*document.Node,
|
||||
resolvedOrigins []map[string]Origin,
|
||||
resolvedPolicies []collectionPolicyScope,
|
||||
inputOrigins map[string]Origin,
|
||||
limits projectionLimits,
|
||||
) []Diagnostic {
|
||||
root := adapter.rebuild(resolved)
|
||||
result.collectionPolicies = collectionPolicyScope{}
|
||||
result.origins = directInheritanceOrigins(&document.Node{Kind: root.Kind, Location: root.Location}, adapter.scopePath(), inputOrigins)
|
||||
policyIndex := newInheritancePolicyIndex(adapter.collectionPolicies())
|
||||
for _, slot := range adapter.slots() {
|
||||
if slot.hasTemplate {
|
||||
index := slot.template
|
||||
projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], templates[index].path)
|
||||
for pointer, origin := range resolvedOrigins[index] {
|
||||
result.origins[pointer] = origin
|
||||
}
|
||||
continue
|
||||
}
|
||||
localPolicies := policyIndex.localize(slot.path)
|
||||
projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, slot.path)
|
||||
for pointer, origin := range directInheritanceOrigins(slot.value, slot.path, inputOrigins) {
|
||||
result.origins[pointer] = origin
|
||||
}
|
||||
}
|
||||
result.root = root
|
||||
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
||||
return []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -74,7 +74,6 @@ func applyInheritanceMetadata(metadata *Metadata, raw *document.Node, rawOrigins
|
||||
})
|
||||
applyOverlayMetadata(&sourceView, overlays)
|
||||
metadata.history = sourceView.history
|
||||
metadata.inheritance = cloneInheritanceOperations(expansion.operations)
|
||||
}
|
||||
|
||||
func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change {
|
||||
|
||||
@@ -124,6 +124,7 @@ func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocume
|
||||
}
|
||||
metadata := metadataFromDecoded(decoded)
|
||||
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
|
||||
metadata.inheritance = cloneInheritanceOperations(expanded.operations)
|
||||
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
|
||||
return &Inspection{
|
||||
metadata: metadata, baseline: baseline, input: expanded.root, preCLI: expanded.root,
|
||||
@@ -388,15 +389,18 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
|
||||
func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) {
|
||||
shared := &document.Node{Kind: document.String, Text: "shared"}
|
||||
extra := &document.Node{Kind: document.String, Text: "extra"}
|
||||
expansion := &inheritanceScope{
|
||||
root: shared,
|
||||
operations: []inheritanceOperation{{parent: shared, effective: shared}},
|
||||
replacements: []inheritanceReplacement{{before: shared, after: extra}},
|
||||
inspection := &Inspection{
|
||||
inheritance: []inheritanceOperation{{parent: shared, effective: shared}},
|
||||
metadata: Metadata{inheritance: []inheritanceOperation{{parent: shared, effective: extra}}},
|
||||
}
|
||||
if !historyNodesWithin(2, nil, nil, nil, nil, nil, expansion) ||
|
||||
historyNodesWithin(1, nil, nil, nil, nil, nil, expansion) {
|
||||
counter := newRetainedNodeCounter(2)
|
||||
if !addInspectionHistory(counter, inspection) || counter.count != 2 {
|
||||
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) {
|
||||
|
||||
@@ -24,6 +24,83 @@ func parseIdentifiedInheritanceSequence(t *testing.T, sourceID, input string) *d
|
||||
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) {
|
||||
sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2
|
||||
tenant: acme
|
||||
@@ -198,11 +275,6 @@ func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *test
|
||||
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
|
||||
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) {
|
||||
|
||||
@@ -44,11 +44,8 @@ func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *tes
|
||||
if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 {
|
||||
t.Fatalf("direct replacement origin = %#v", count)
|
||||
}
|
||||
if len(result.replacements) != 1 || result.replacements[0].path.String() != "/child/count" ||
|
||||
result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" ||
|
||||
len(result.replacements[0].previous) != 1 ||
|
||||
len(projectInheritanceSteps(result.replacements[0].previous[0].origin)) != 1 {
|
||||
t.Fatalf("displaced inheritance = %#v", result.replacements)
|
||||
if len(result.replacements) != 1 || result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" {
|
||||
t.Fatalf("replacement accounting values = %#v", result.replacements)
|
||||
}
|
||||
settings := result.origins["/child/settings"]
|
||||
if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" ||
|
||||
|
||||
88
internal/engine/inheritance_prepare.go
Normal file
88
internal/engine/inheritance_prepare.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type preparedInheritanceChild struct {
|
||||
payload *document.Node
|
||||
origins map[string]Origin
|
||||
policyTemplates collectionPolicyScope
|
||||
control *inheritanceControl
|
||||
controlOrigin Origin
|
||||
}
|
||||
|
||||
type preparedInheritanceParent struct {
|
||||
payload *document.Node
|
||||
historyParent *document.Node
|
||||
policyTemplates []collectionPolicyScope
|
||||
effectivePolicyTemplates []collectionPolicyScope
|
||||
parentPolicyScope collectionPolicyScope
|
||||
childPolicyScope collectionPolicyScope
|
||||
replaced collectionReplacementPaths
|
||||
transfer inheritanceTransfer
|
||||
}
|
||||
|
||||
func prepareInheritanceChild(template inheritanceExecutionTemplate, inputOrigins map[string]Origin) preparedInheritanceChild {
|
||||
policyTemplates := cloneCollectionPolicyScopeForInheritance(template.collectionPolicies)
|
||||
payload := withoutInheritanceDirective(template.entry.Value)
|
||||
prepared := preparedInheritanceChild{
|
||||
payload: payload, origins: directInheritanceOrigins(payload, template.path, inputOrigins),
|
||||
policyTemplates: policyTemplates,
|
||||
}
|
||||
if template.hasDirective {
|
||||
control := inheritanceControl{
|
||||
path: template.directivePath, locations: []Location{publicLocation(template.directive.Value.Location)},
|
||||
cancelled: template.cancelled,
|
||||
}
|
||||
controlOrigins := directInheritanceOrigins(template.directive.Value, control.path, inputOrigins)
|
||||
prepared.control = &control
|
||||
prepared.controlOrigin = controlOrigins[control.path.String()]
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func prepareInheritanceParent(
|
||||
template inheritanceExecutionTemplate,
|
||||
parentPath Path,
|
||||
parent *document.Node,
|
||||
resolvedParentPolicyTemplates []collectionPolicyScope,
|
||||
resolvedParentPolicies collectionPolicyScope,
|
||||
child preparedInheritanceChild,
|
||||
applicationPolicy sequencePolicy,
|
||||
) preparedInheritanceParent {
|
||||
parentPolicies := cloneInheritancePolicyTemplates(resolvedParentPolicyTemplates)
|
||||
effectivePolicies := append(
|
||||
cloneInheritancePolicyTemplates(parentPolicies),
|
||||
cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
|
||||
)
|
||||
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedParentPolicies, template.path)
|
||||
childPolicyScope := projectCurrentInheritanceCollectionPolicies(child.policyTemplates, template.path)
|
||||
var replaced collectionReplacementPaths
|
||||
if len(parentPolicyScope.collections) != 0 || len(childPolicyScope.collections) != 0 {
|
||||
claims := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||
claims.append(child.payload, template.path, 0)
|
||||
declarations := make(map[string][]sequenceDeclarationSpec, len(childPolicyScope.collections))
|
||||
for key, entry := range childPolicyScope.collections {
|
||||
declarations[key] = inheritanceDeclarationsAt(entry.destination, entry.policy)
|
||||
}
|
||||
parentPolicyScope, replaced = lowerCollectionPoliciesForClaims(parentPolicyScope, claims.ordered, declarations, applicationPolicy)
|
||||
}
|
||||
parentPayload := inheritanceParentPayload(parent, template.identityFields)
|
||||
historyParent := parent
|
||||
if len(template.identityFields) != 0 {
|
||||
// The retained parent snapshot is the inherited payload, before child
|
||||
// fields are applied, with parent identity excluded.
|
||||
historyParent = document.Clone(parentPayload)
|
||||
}
|
||||
transfer := inheritanceTransfer{
|
||||
from: parentPath, to: template.path, reference: template.directivePath,
|
||||
locations: []Location{publicLocation(template.directive.Value.Location)},
|
||||
policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(child.policyTemplates),
|
||||
projectedPolicies: projectInheritancePolicyTemplates([]collectionPolicyScope{resolvedParentPolicies}, template.path),
|
||||
}
|
||||
return preparedInheritanceParent{
|
||||
payload: parentPayload, historyParent: historyParent,
|
||||
policyTemplates: parentPolicies, effectivePolicyTemplates: effectivePolicies,
|
||||
parentPolicyScope: parentPolicyScope, childPolicyScope: childPolicyScope,
|
||||
replaced: replaced, transfer: transfer,
|
||||
}
|
||||
}
|
||||
@@ -188,193 +188,52 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
|
||||
|
||||
for _, index := range order {
|
||||
node := templates[index]
|
||||
templatePolicies := cloneCollectionPolicyScopeForInheritance(node.collectionPolicies)
|
||||
payload := withoutInheritanceDirective(node.entry.Value)
|
||||
operationStart, replacementStart := len(result.operations), len(result.replacements)
|
||||
childOrigins := directInheritanceOrigins(payload, node.path, inputOrigins)
|
||||
if node.hasDirective {
|
||||
control := inheritanceControl{
|
||||
path: node.directivePath,
|
||||
locations: []Location{publicLocation(node.directive.Value.Location)},
|
||||
cancelled: node.cancelled,
|
||||
}
|
||||
result.controls = append(result.controls, control)
|
||||
controlOrigins := directInheritanceOrigins(node.directive.Value, control.path, inputOrigins)
|
||||
result.operations = append(result.operations, inheritanceOperation{
|
||||
control: &control, controlOrigin: controlOrigins[control.path.String()],
|
||||
})
|
||||
}
|
||||
child := prepareInheritanceChild(node, inputOrigins)
|
||||
if limits.nodes >= 0 {
|
||||
remaining := limits.nodes - retained - adapter.slotOverhead()
|
||||
within := inheritanceLowerBoundWithin(payload, remaining)
|
||||
within := inheritanceLowerBoundWithin(child.payload, remaining)
|
||||
if node.hasParent {
|
||||
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], payload, remaining)
|
||||
within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], child.payload, remaining)
|
||||
}
|
||||
if !within {
|
||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||
}
|
||||
}
|
||||
|
||||
var parent *preparedInheritanceParent
|
||||
var parentOrigins map[string]Origin
|
||||
if node.hasParent {
|
||||
parent := resolved[node.parent]
|
||||
parentPolicies := cloneInheritancePolicyTemplates(resolvedPolicyTemplates[node.parent])
|
||||
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
|
||||
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
|
||||
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
|
||||
var replaced collectionReplacementPaths
|
||||
if len(parentPolicyScope.collections) != 0 || len(childPolicyScope.collections) != 0 {
|
||||
claims := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||
claims.append(payload, node.path, 0)
|
||||
declarations := make(map[string][]sequenceDeclarationSpec, len(childPolicyScope.collections))
|
||||
for key, entry := range childPolicyScope.collections {
|
||||
declarations[key] = inheritanceDeclarationsAt(entry.destination, entry.policy)
|
||||
}
|
||||
parentPolicyScope, replaced = lowerCollectionPoliciesForClaims(parentPolicyScope, claims.ordered, declarations, adapter.applicationPolicy())
|
||||
}
|
||||
merged := inheritanceParentPayload(parent, node.identityFields)
|
||||
historyParent := parent
|
||||
if len(node.identityFields) != 0 {
|
||||
// The retained parent snapshot is the inherited payload, before
|
||||
// child fields are applied, with parent identity excluded.
|
||||
historyParent = document.Clone(merged)
|
||||
}
|
||||
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
|
||||
parentPolicyScope, childPolicyScope, node.path, parent, payload, adapter.applicationPolicy(), replaced,
|
||||
prepared := prepareInheritanceParent(
|
||||
node, templates[node.parent].path, resolved[node.parent], resolvedPolicyTemplates[node.parent],
|
||||
resolvedPolicies[node.parent], child, adapter.applicationPolicy(),
|
||||
)
|
||||
if len(policyDiagnostics) != 0 {
|
||||
return nil, policyDiagnostics
|
||||
parent = &prepared
|
||||
parentOrigins = resolvedOrigins[node.parent]
|
||||
}
|
||||
transfer := inheritanceTransfer{
|
||||
from: templates[node.parent].path, to: node.path,
|
||||
reference: node.directivePath,
|
||||
locations: []Location{publicLocation(node.directive.Value.Location)},
|
||||
policyTemplates: parentPolicies, templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
|
||||
projectedPolicies: projectInheritancePolicyTemplates(
|
||||
[]collectionPolicyScope{resolvedPolicies[node.parent]}, node.path,
|
||||
),
|
||||
}
|
||||
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[node.parent], transfer.from, node.path, transfer)
|
||||
mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()])
|
||||
var sequenceContributions []sequenceHistoryContribution
|
||||
var combinationDiagnostics []Diagnostic
|
||||
mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{
|
||||
added: func(childPath Path, incoming *document.Node) {
|
||||
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
||||
},
|
||||
combined: func(childPath Path, _ *document.Node) {
|
||||
mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()])
|
||||
},
|
||||
replaced: func(childPath Path, existing, incoming *document.Node) {
|
||||
previous := displacedOrigins(existing, mergedOrigins, childPath)
|
||||
result.replacements = append(result.replacements, inheritanceReplacement{
|
||||
path: childPath.clone(), before: existing, after: incoming,
|
||||
previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]),
|
||||
})
|
||||
deleteOriginSubtree(mergedOrigins, childPath, existing)
|
||||
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
|
||||
},
|
||||
merge: func(childPath Path, existing, incoming *document.Node) (*document.Node, bool) {
|
||||
resolution, _, _, active, issues := inheritanceCollectionResolution(
|
||||
childPath, parentPolicyScope, childPolicyScope, adapter.applicationPolicy(), incoming,
|
||||
templateResult, diagnostics := combineInheritanceTemplate(
|
||||
node, child, parent, parentOrigins, adapter.applicationPolicy(),
|
||||
)
|
||||
if len(issues) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||
return existing, false
|
||||
}
|
||||
if !active {
|
||||
return nil, false
|
||||
}
|
||||
lowerValue := existing
|
||||
combined, problems := combineSequence(lowerValue, incoming, resolution, sequenceCombinationContext{destination: childPath})
|
||||
if len(problems) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, problems...)
|
||||
return existing, false
|
||||
}
|
||||
policy := applyInheritanceSequencePolicy(
|
||||
childPath, combined, resolution, parentPolicyScope, childPolicyScope,
|
||||
)
|
||||
mergedPolicyScope.set(childPath, policy)
|
||||
applyInheritanceSequenceOrigins(
|
||||
result, mergedOrigins, childOrigins, childPath, lowerValue, incoming, combined, resolution.operation,
|
||||
)
|
||||
history := sequenceHistoryContribution{path: childPath.clone(), mode: sequenceHistoryMapped}
|
||||
if resolution.operation == sequenceModeReplace {
|
||||
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
|
||||
},
|
||||
})
|
||||
preserveInheritanceIdentityFields(merged, payload, node.identityFields)
|
||||
if len(combinationDiagnostics) != 0 {
|
||||
return nil, canonicalDiagnostics(combinationDiagnostics)
|
||||
}
|
||||
if problems := validateRegisteredInheritanceSequences(merged, node.path, adapter.applicationPolicy()); len(problems) != 0 {
|
||||
return nil, problems
|
||||
}
|
||||
merged.Location = node.entry.Value.Location
|
||||
resolved[index], resolvedOrigins[index] = merged, mergedOrigins
|
||||
resolvedPolicies[index] = inheritancePolicyScopeAtPath(mergedPolicyScope, node.path)
|
||||
result.transfers = append(result.transfers, transfer)
|
||||
result.operations = append(result.operations, inheritanceOperation{
|
||||
transfer: &transfer, parent: historyParent, effective: merged,
|
||||
parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins,
|
||||
parentPolicies: cloneInheritancePolicyTemplates(parentPolicies),
|
||||
templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
|
||||
effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies),
|
||||
sequences: cloneSequenceHistoryContributions(sequenceContributions),
|
||||
identityFields: cloneStrings(node.identityFields),
|
||||
})
|
||||
resolvedPolicyTemplates[index] = effectivePolicies
|
||||
} else {
|
||||
resolved[index] = document.Clone(payload)
|
||||
resolvedOrigins[index] = childOrigins
|
||||
resolvedPolicyTemplates[index] = []collectionPolicyScope{templatePolicies}
|
||||
resolvedPolicies[index] = cloneCollectionPolicyScopeForInheritance(templatePolicies)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
resolved[index] = templateResult.value
|
||||
resolvedOrigins[index] = templateResult.origins
|
||||
resolvedPolicies[index] = templateResult.collectionPolicies
|
||||
resolvedPolicyTemplates[index] = templateResult.policyTemplates
|
||||
result.controls = append(result.controls, templateResult.controls...)
|
||||
result.transfers = append(result.transfers, templateResult.transfers...)
|
||||
result.replacements = append(result.replacements, templateResult.replacements...)
|
||||
result.operations = append(result.operations, templateResult.operations...)
|
||||
|
||||
count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes)
|
||||
retained += adapter.slotOverhead() + count
|
||||
for _, operation := range result.operations[operationStart:] {
|
||||
if !counter.add(operation.parent) || !counter.add(operation.effective) {
|
||||
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||
}
|
||||
}
|
||||
for _, replacement := range result.replacements[replacementStart:] {
|
||||
if !counter.add(replacement.before) || !counter.add(replacement.after) {
|
||||
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)}
|
||||
}
|
||||
if diagnostic := chargeInheritanceTemplateHistory(counter, templateResult, node); diagnostic != nil {
|
||||
return nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
|
||||
root := adapter.rebuild(resolved)
|
||||
result.collectionPolicies = collectionPolicyScope{}
|
||||
result.origins = directInheritanceOrigins(&document.Node{Kind: root.Kind, Location: root.Location}, adapter.scopePath(), inputOrigins)
|
||||
policyIndex := newInheritancePolicyIndex(adapter.collectionPolicies())
|
||||
for _, slot := range adapter.slots() {
|
||||
if slot.hasTemplate {
|
||||
index := slot.template
|
||||
projectInheritancePolicyRoots(&result.collectionPolicies, resolvedPolicies[index], templates[index].path)
|
||||
for pointer, origin := range resolvedOrigins[index] {
|
||||
result.origins[pointer] = origin
|
||||
}
|
||||
continue
|
||||
}
|
||||
localPolicies := policyIndex.localize(slot.path)
|
||||
projectInheritancePolicyRoots(&result.collectionPolicies, localPolicies, slot.path)
|
||||
for pointer, origin := range directInheritanceOrigins(slot.value, slot.path, inputOrigins) {
|
||||
result.origins[pointer] = origin
|
||||
}
|
||||
}
|
||||
result.root = root
|
||||
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
||||
return nil, []Diagnostic{inheritanceBudgetDiagnostic(adapter.scopePath(), adapter.scope().Location)}
|
||||
if diagnostics := finalizeInheritanceScope(adapter, result, templates, resolved, resolvedOrigins, resolvedPolicies, inputOrigins, limits); len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
return
|
||||
}
|
||||
identityFields := make(map[string]struct{}, len(fields))
|
||||
for _, field := range fields {
|
||||
identityFields[field] = struct{}{}
|
||||
}
|
||||
positions := make(map[string]int, len(merged.Mapping))
|
||||
for index, entry := range merged.Mapping {
|
||||
if _, exists := positions[entry.Key]; !exists {
|
||||
positions[entry.Key] = index
|
||||
}
|
||||
}
|
||||
for _, entry := range child.Mapping {
|
||||
if entry.Key != field {
|
||||
if _, identity := identityFields[entry.Key]; !identity {
|
||||
continue
|
||||
}
|
||||
for index := range merged.Mapping {
|
||||
if merged.Mapping[index].Key == field {
|
||||
merged.Mapping[index] = document.Entry{Key: field, Value: document.Clone(entry.Value)}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
delete(identityFields, entry.Key)
|
||||
if index, exists := positions[entry.Key]; exists {
|
||||
entry.Value = document.Clone(entry.Value)
|
||||
merged.Mapping[index] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestPendingItemCompositionProjectsNestedIdentityDiagnostics(t *testing.T) {
|
||||
destination: Keys("items"), pendingItem: &itemScope,
|
||||
}}}
|
||||
|
||||
traversal := importTraversal{accounting: &importAccounting{}, limits: defaultImportLimits()}
|
||||
traversal := compositionTraversal(sequencePolicy{}, nil, nil, &importAccounting{}, newImportInclusionGraph())
|
||||
diagnostics := traversal.composePendingItems(&outer)
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
|
||||
@@ -129,7 +129,7 @@ func TestPendingItemCompositionAppliesOuterApplicationIdentityConstraints(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacingOuterCollectionRetiresDiscardedItemIdentity(t *testing.T) {
|
||||
func TestStrictCompositionDoesNotCopyDiscardedItemPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outerPath := Keys("items")
|
||||
@@ -148,9 +148,27 @@ func TestReplacingOuterCollectionRetiresDiscardedItemIdentity(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose replacement: %#v", diagnostics)
|
||||
}
|
||||
policy, ok := composed.collectionPolicies.at(nestedPath)
|
||||
if !ok || policy.identityPresent || len(policy.recordOrder) != 0 {
|
||||
t.Fatalf("discarded nested owner policy = %#v, %v", policy, ok)
|
||||
if _, ok := composed.collectionPolicies.at(nestedPath); ok {
|
||||
t.Fatal("strict composition copied an unclaimed nested policy into its sparse result")
|
||||
}
|
||||
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
|
||||
Filesystem *FilesystemContext
|
||||
Destination Path
|
||||
placementMode placementMode
|
||||
PlacementMode PlacementMode
|
||||
}
|
||||
|
||||
// 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.
|
||||
type LayeredOptions[T any] struct {
|
||||
Definition Definition[T]
|
||||
importDefaults importDefaults
|
||||
ImportDefaults ImportDefaults
|
||||
SourceVersionMode sourceVersionMode
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
@@ -38,10 +38,10 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
request := loadRequest[T]{
|
||||
main: loadInput{
|
||||
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,
|
||||
importDefaults: snapshotImportDefaults(options.importDefaults),
|
||||
importDefaults: options.ImportDefaults,
|
||||
overlay: options.Overlay,
|
||||
overlayFilesystem: options.OverlayFilesystem,
|
||||
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 {
|
||||
request.underlays[index].inputs[inputIndex] = loadInput{
|
||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
||||
destination: input.Destination.clone(), placementMode: input.placementMode,
|
||||
destination: input.Destination.clone(), placementMode: input.PlacementMode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,9 @@ type Options[T any] struct {
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
SourceVersionMode sourceVersionMode
|
||||
importDefaults importDefaults
|
||||
destination Path
|
||||
placementMode placementMode
|
||||
sequencePolicy sequencePolicy
|
||||
deferAssembly bool
|
||||
directiveContext rootDirectiveContext
|
||||
importDefaultsValidated bool
|
||||
ImportDefaults ImportDefaults
|
||||
Destination Path
|
||||
PlacementMode PlacementMode
|
||||
}
|
||||
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays. For an
|
||||
@@ -97,7 +93,6 @@ type Inspection struct {
|
||||
baseline *document.Node
|
||||
input *document.Node
|
||||
inputOrigins map[string]Origin
|
||||
structuralInput *document.Node
|
||||
preCLI *document.Node
|
||||
preCLIOrigins map[string]Origin
|
||||
postDecodeApps []historyApplication
|
||||
@@ -153,38 +148,46 @@ 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) {
|
||||
options, diagnostics := selectOptions(source, supplied)
|
||||
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
|
||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||
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 {
|
||||
return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}}).loadError()
|
||||
}
|
||||
var options Options[T]
|
||||
if len(supplied) == 1 {
|
||||
options = supplied[0]
|
||||
}
|
||||
input, diagnostics := normalizeInput(loadInput{
|
||||
source: source, imports: options.Imports, filesystem: options.Filesystem,
|
||||
destination: options.Destination.clone(), placementMode: options.PlacementMode,
|
||||
}, options.Overlay)
|
||||
defaults, policy, settingsDiagnostics := loadSettings(options.ImportDefaults, options.Definition, options.SourceVersionMode)
|
||||
diagnostics = append(diagnostics, settingsDiagnostics...)
|
||||
diagnostics = append(diagnostics, overlayOptionDiagnostics(options.Overlay)...)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
||||
}
|
||||
definition := options.Definition
|
||||
definition.sequenceIdentityRegistrations = nil
|
||||
request := loadRequest[T]{
|
||||
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
||||
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,
|
||||
main: input,
|
||||
definition: definition, importDefaults: defaults, sequencePolicy: policy, overlay: options.Overlay,
|
||||
overrides: options.Overrides, ordinary: true,
|
||||
sourceVersionMode: options.SourceVersionMode,
|
||||
effectiveVersion: effectiveSourceVersionRuleForPlacement(options.Definition.Version, options.destination, options.SourceVersionMode, options.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{}
|
||||
accounting := &importAccounting{}
|
||||
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), placementMode: request.main.placementMode}
|
||||
mainOptions.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||
mainOptions.sequencePolicy = request.sequencePolicy
|
||||
mainOptions.deferAssembly = true
|
||||
mainOptions.SourceVersionMode = request.sourceVersionMode
|
||||
mainOptions.Definition.Version = request.protectionVersion()
|
||||
if request.ordinary {
|
||||
mainOptions.Overlay = request.overlay
|
||||
mainOptions.directiveContext = rootDirectiveContextSource
|
||||
} else {
|
||||
mainOptions.directiveContext = rootDirectiveContextLayered
|
||||
policy := acquisitionPolicy{
|
||||
importDefaults: request.importDefaults,
|
||||
sequencePolicy: request.sequencePolicy,
|
||||
importLimits: defaultImportLimits(),
|
||||
}
|
||||
|
||||
var schema *schemaDescriptor
|
||||
@@ -198,10 +201,20 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
}
|
||||
versionState := &sourceVersionState{
|
||||
localRule: request.definition.Version, effectiveRule: request.protectionVersion(), mode: request.sourceVersionMode,
|
||||
importDefaults: snapshotImportDefaults(request.importDefaults),
|
||||
}
|
||||
state := &acquisitionState{accounting: accounting, versionState: versionState}
|
||||
accumulator.versionState = versionState
|
||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||
directiveContext := rootDirectiveContextLayered
|
||||
ordinaryOverlay := ""
|
||||
if request.ordinary {
|
||||
directiveContext = rootDirectiveContextSource
|
||||
ordinaryOverlay = request.overlay
|
||||
}
|
||||
input, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||
input: request.main, role: sourceVersionMain, directiveContext: directiveContext,
|
||||
protectionRule: request.protectionVersion(),
|
||||
ordinaryOverlay: ordinaryOverlay,
|
||||
}, policy, state, &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute)})
|
||||
request.main.source = input.source
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(versionState.diagnostics...)
|
||||
@@ -219,7 +232,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
}
|
||||
}
|
||||
if request.ordinary && (input.document != nil || input.composition != nil) {
|
||||
input.composition, diagnostics = compositionForInputWithPolicy(input, request.sequencePolicy)
|
||||
input.composition, diagnostics = compositionForInputWithPolicy(input, policy.sequencePolicy)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
@@ -235,10 +248,9 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
}
|
||||
if request.ordinary {
|
||||
versionState.locateMainVersion(input.document)
|
||||
mainOptions.Definition.Version = versionState.effectiveRule
|
||||
input, diagnostics = applyInputLayers(request.main.source, mainOptions, input)
|
||||
input, diagnostics = applyInputLayers(request.main.source, request.main.filesystem, request.overlay, versionState.effectiveRule, policy.sequencePolicy, input)
|
||||
} else {
|
||||
input, diagnostics = assembleLayeredInput(request, input, accounting)
|
||||
input, diagnostics = assembleLayeredInput(request, input, policy, state)
|
||||
}
|
||||
accumulator.add(versionState.diagnostics[reportedVersionDiagnostics:]...)
|
||||
accumulator.sources = input.sources
|
||||
@@ -248,44 +260,22 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
}
|
||||
request.effectiveVersion = versionState.effectiveRule
|
||||
if request.ordinary && input.parsed.source != nil {
|
||||
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 {
|
||||
contexts, diagnostic := contextsForAcquiredSources(request.main.source, request.main.filesystem, input.parsed.source, baseSources)
|
||||
if diagnostic != nil {
|
||||
accumulator.add(*diagnostic)
|
||||
input, diagnostics = completeOrdinaryResourceContexts(input, request.main)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
mergeResourceContexts(&input.contexts, contexts)
|
||||
}
|
||||
}
|
||||
if input.document != nil && containsInheritanceDirective(input.document) {
|
||||
composition, problems := compositionForInput(input)
|
||||
if len(problems) != 0 {
|
||||
accumulator.add(problems...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
expanded, problems := expandInheritanceWithCollectionRules(
|
||||
composition.root, composition.origins, composition.collectionPolicies, request.sequencePolicy, defaultProjectionLimits(),
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
accumulator.add(problems...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
effectiveVersion, problem := projectInheritanceVersion(request.protectionVersion(), expanded)
|
||||
if problem != nil {
|
||||
accumulator.add(*problem)
|
||||
var expanded bool
|
||||
var effectiveVersion *VersionRule
|
||||
input, effectiveVersion, expanded, diagnostics = expandStructuralInheritance(input, request.protectionVersion(), policy.sequencePolicy)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
if expanded {
|
||||
request.effectiveVersion = effectiveVersion
|
||||
versionState.effectiveRule = effectiveVersion
|
||||
input.composition = composition
|
||||
input.inheritance = expanded
|
||||
input.document = expanded.root
|
||||
}
|
||||
|
||||
base := overridePreparationInput{root: input.document}
|
||||
@@ -325,8 +315,16 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
historyApplications := append(cloneHistoryApplications(resolved.applications), cloneHistoryApplications(resolved.postDecodeApps)...)
|
||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, historyApplications, resolved.snapshots, input.inheritance) {
|
||||
counter := newRetainedNodeCounter(historyLimit)
|
||||
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"})
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
@@ -342,7 +340,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
if hasErrors(secretResolution.diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, request.sequencePolicy)
|
||||
identityDiagnostics := validateFinalSequenceIdentities(resolved.referenceInput, policy.sequencePolicy)
|
||||
accumulator.add(identityDiagnostics...)
|
||||
if hasErrors(identityDiagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
@@ -357,5 +355,5 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
resolved.configuration = configuration
|
||||
// Publication detaches equal-valued validator replacements, preserving the
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
@@ -18,6 +16,7 @@ type assembledInput struct {
|
||||
contexts map[string]ResourceContext
|
||||
versionState *sourceVersionState
|
||||
accounting *importAccounting
|
||||
resolvedFilesystem *resolvedFilesystemContext
|
||||
}
|
||||
|
||||
// acquiredContribution keeps a source-local payload beside its placed document.
|
||||
@@ -89,253 +88,96 @@ func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope {
|
||||
return cloned
|
||||
}
|
||||
|
||||
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
||||
return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil)
|
||||
}
|
||||
|
||||
func assembleBaseInputTracked[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
|
||||
return assembleBaseInputTrackedWithVersion(source, options, destination, accounting, visited, hostToken, nil)
|
||||
}
|
||||
|
||||
func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState) (assembledInput, []Diagnostic) {
|
||||
return assembleBaseInputTrackedForRole(source, options, destination, accounting, visited, hostToken, versionState, sourceVersionMain)
|
||||
}
|
||||
|
||||
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
||||
sourceID := "source-" + strconv.Itoa(accounting.sources+1)
|
||||
selectedSource := source
|
||||
if source.kind == sourceSearch {
|
||||
resolved, failedSource, diagnostics := resolveSearchInput(source, sourceID, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe())
|
||||
if len(diagnostics) != 0 {
|
||||
if failedSource != nil {
|
||||
failedSource.Destination = destination.clone()
|
||||
failedSource.placementMode = options.placementMode
|
||||
}
|
||||
return assembledInput{source: resolved, sources: sourceInfos(failedSource), versionState: versionState, accounting: accounting}, diagnostics
|
||||
}
|
||||
selectedSource = resolved
|
||||
if options.Filesystem != nil {
|
||||
if diagnostics := filesystemOptionDiagnostics(selectedSource, options.Filesystem); len(diagnostics) != 0 {
|
||||
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
|
||||
}
|
||||
}
|
||||
if selectedSource.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
|
||||
}
|
||||
}
|
||||
limits := defaultImportLimits()
|
||||
parseLimits := defaultParseLimits()
|
||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||
parsed, diagnostics := parseSource(selectedSource, sourceID, parseLimits)
|
||||
if parsed.source != nil {
|
||||
parsed.source.Destination = destination.clone()
|
||||
parsed.source.placementMode = options.placementMode
|
||||
}
|
||||
input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
||||
if len(diagnostics) != 0 {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
// Mapping value issues already identify application content. Other early
|
||||
// failures describe source syntax or access, not a placed value.
|
||||
if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 {
|
||||
for index := range diagnostics {
|
||||
relative := diagnostics[index].Path
|
||||
segments := clonePathSegments(destination.segments, len(relative.segments))
|
||||
diagnostics[index].Path = Path{segments: append(segments, relative.segments...)}
|
||||
}
|
||||
}
|
||||
return input, diagnostics
|
||||
}
|
||||
directiveContext := options.directiveContext
|
||||
if directiveContext == 0 {
|
||||
directiveContext = rootDirectiveContextImportsOnly
|
||||
}
|
||||
if !options.Imports && parsed.document != nil {
|
||||
var directiveDiagnostics []Diagnostic
|
||||
if directiveContext == rootDirectiveContextSource || directiveContext == rootDirectiveContextLayered {
|
||||
directiveDiagnostics = disabledRootImportDiagnostics(parsed.document)
|
||||
} else {
|
||||
directiveDiagnostics = disabledDirectiveDiagnostics(parsed.document, Path{})
|
||||
}
|
||||
if len(directiveDiagnostics) != 0 {
|
||||
return input, directiveDiagnostics
|
||||
}
|
||||
}
|
||||
var versionProcessing *sourceVersionProcessingResult
|
||||
if directiveContext == rootDirectiveContextSource {
|
||||
var sourceID string
|
||||
if parsed.source != nil {
|
||||
sourceID = parsed.source.ID
|
||||
}
|
||||
var processed sourceVersionProcessingResult
|
||||
var failures []Diagnostic
|
||||
if versionState != nil {
|
||||
processed, failures = versionState.processWithDirectiveContext(parsed.document, role, destination, sourceID, options.placementMode, directiveContext)
|
||||
} else {
|
||||
processed = processSourceVersionWithDirectiveContext(parsed.document, options.Definition.Version, 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, options.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, options.placementMode)
|
||||
if len(failures) != 0 {
|
||||
return input, failures
|
||||
}
|
||||
versionProcessing = &processed
|
||||
if role == sourceVersionMain {
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
}
|
||||
if options.Imports && selectedSource.kind != sourceNone {
|
||||
if options.deferAssembly {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
||||
} else {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if input.composition != nil {
|
||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||
input.document = input.composition.root
|
||||
}
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if versionProcessing != nil {
|
||||
input.document = versionProcessing.payload
|
||||
}
|
||||
if options.Filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(options.Overlay)) {
|
||||
if diagnostic := validateFilesystemContext(selectedSource, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !options.Imports && input.document != nil {
|
||||
_, placed, err := placeDocumentWithMode(input.document, destination, options.placementMode, input.document.Location, parseLimits.document.Depth, parseLimits.document.Nodes)
|
||||
if err != nil {
|
||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
}
|
||||
localPayload := input.document
|
||||
input.document = placed
|
||||
identity := importKeyForMainSource(parsed.source)
|
||||
var sequenceDeclarations []sequenceDeclarationSpec
|
||||
if versionProcessing != nil {
|
||||
sequenceDeclarations = cloneSequenceDeclarations(versionProcessing.sequences)
|
||||
}
|
||||
input.acquisition = []acquisitionScope{{
|
||||
owner: identity,
|
||||
destination: destination.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(*parsed.source), payload: localPayload,
|
||||
identity: identity, role: importRoleMain, scopeOwner: identity,
|
||||
document: placed, destination: destination.clone(), processing: versionProcessing,
|
||||
sequenceDeclarations: sequenceDeclarations,
|
||||
}},
|
||||
}}
|
||||
rawNodes := countInputNodes(parsed.document)
|
||||
placementNodes := destinationWrapperNodeCountForMode(destination, options.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, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += parsed.bytes
|
||||
accounting.nodes += rawNodes + placementNodes
|
||||
}
|
||||
if !activeOverlay(options.Overlay) || options.Imports {
|
||||
contexts, diagnostic := contextsForAcquiredSources(selectedSource, options.Filesystem, parsed.source, input.sources)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.contexts = contexts
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
|
||||
if visited == nil || info == nil || info.Path == "" {
|
||||
return nil
|
||||
}
|
||||
identity := resourceIdentity{namespace: hostToken, path: info.Path}
|
||||
if source.kind == sourceFilesystem {
|
||||
identity.namespace = source.namespace.token
|
||||
} else if context != nil && context.FS != nil {
|
||||
identity.namespace = context.FS.namespace.token
|
||||
}
|
||||
if previous, repeated := visited[identity]; repeated {
|
||||
value := importRepeatedDiagnostic(Location{SourceID: info.ID}, previous, importRoute{})
|
||||
return &value
|
||||
}
|
||||
visited[identity] = importRoute{canonical: info.Path}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
||||
if !activeOverlay(options.Overlay) {
|
||||
func applyInputLayers(source Source, filesystem *FilesystemContext, overlay string, protectionRule *VersionRule, policy sequencePolicy, input assembledInput) (assembledInput, []Diagnostic) {
|
||||
if !activeOverlay(overlay) {
|
||||
return input, nil
|
||||
}
|
||||
composition, diagnostics := compositionForInputWithPolicy(input, options.sequencePolicy)
|
||||
composition, diagnostics := compositionForInputWithPolicy(input, policy)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
assembly := newAssemblyAccumulator(composition)
|
||||
if activeOverlay(options.Overlay) {
|
||||
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(
|
||||
source, options.Filesystem, input.parsed, assembly, input.sources,
|
||||
options.Overlay, options.Definition.Version, defaultImportLimits(), input.versionState, options.sequencePolicy,
|
||||
)
|
||||
resolved, diagnostic := resolveOrdinaryOverlayFilesystem(source, filesystem, input.parsed, input.resolvedFilesystem)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if source.kind != sourceNone || nativeContext(source, filesystem) {
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
request := selectedOverlayRequest{
|
||||
name: overlay, protectionRule: protectionRule,
|
||||
sequencePolicy: policy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||
}
|
||||
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.sources, diagnostics = loadSelectedOverlay(request, access, assembly, input.sources)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, input.parsed.source, input.sources[len(input.sources)-1:])
|
||||
input.resolvedFilesystem = &resolved
|
||||
contexts, diagnostic := contextsForResolvedSources(&resolved, input.sources[len(input.sources)-1:])
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
mergeResourceContexts(&input.contexts, contexts)
|
||||
}
|
||||
input.composition = assembly.current
|
||||
input.document = assembly.current.root
|
||||
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
|
||||
// expansion. Raw source bytes and mapping keys retain their budgets.
|
||||
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
||||
|
||||
@@ -10,7 +10,7 @@ type loadRequest[T any] struct {
|
||||
main loadInput
|
||||
underlays []loadGroup
|
||||
definition Definition[T]
|
||||
importDefaults importDefaults
|
||||
importDefaults ImportDefaults
|
||||
sequencePolicy sequencePolicy
|
||||
sourceVersionMode sourceVersionMode
|
||||
effectiveVersion *VersionRule
|
||||
@@ -32,7 +32,7 @@ type loadInput struct {
|
||||
imports bool
|
||||
filesystem *FilesystemContext
|
||||
destination Path
|
||||
placementMode placementMode
|
||||
placementMode PlacementMode
|
||||
}
|
||||
|
||||
type loadGroup struct{ inputs []loadInput }
|
||||
@@ -48,36 +48,28 @@ type acquiredUnderlayGroup struct {
|
||||
}
|
||||
|
||||
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
|
||||
var diagnostics []Diagnostic
|
||||
request.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
|
||||
policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations)
|
||||
defaults, policy, diagnostics := loadSettings(request.importDefaults, request.definition, request.sourceVersionMode)
|
||||
request.importDefaults = defaults
|
||||
request.sequencePolicy = policy
|
||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||
request.definition.sequenceIdentityRegistrations = nil
|
||||
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
||||
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
||||
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
||||
}})
|
||||
request.main.destination = mainOptions.destination
|
||||
main, mainDiagnostics := normalizeInput(request.main, "")
|
||||
request.main = main
|
||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||
request.effectiveVersion = effectiveSourceVersionRuleForPlacement(request.definition.Version, request.main.destination, request.sourceVersionMode, request.main.placementMode)
|
||||
for groupIndex := range request.underlays {
|
||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{
|
||||
Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode,
|
||||
importDefaults: request.importDefaults, importDefaultsValidated: true,
|
||||
}})
|
||||
input.destination = inputOptions.destination
|
||||
normalized, inputDiagnostics := normalizeInput(*input, "")
|
||||
*input = normalized
|
||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||
}
|
||||
}
|
||||
if !validOverlayName(request.overlay) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||
}
|
||||
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
||||
if activeOverlay(request.overlay) {
|
||||
if request.overlayFilesystem == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
||||
@@ -88,17 +80,17 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
|
||||
if len(diagnostics) != 0 {
|
||||
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, accounting *importAccounting) (assembledInput, []Diagnostic) {
|
||||
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, policy acquisitionPolicy, state *acquisitionState) (assembledInput, []Diagnostic) {
|
||||
var assembly *assemblyAccumulator
|
||||
mainSourceCount := len(input.sources)
|
||||
mainEvidence := input.versionState.evidenceSince(0)
|
||||
projectVersion := sequenceVersionPath(request.protectionVersion())
|
||||
for index, group := range request.underlays {
|
||||
evidenceStart := len(input.versionState.evidenceSince(0))
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), accounting, input.versionState, request.sequencePolicy)
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy)
|
||||
mergeResourceContexts(&input.contexts, acquired.contexts)
|
||||
if len(diagnostics) != 0 {
|
||||
input.sources = append(input.sources, acquired.sources...)
|
||||
@@ -114,29 +106,20 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
// Source-local checks still run during acquisition above.
|
||||
protectionRule = nil
|
||||
}
|
||||
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, request.sequencePolicy, lower)
|
||||
composition, diagnostics := composeUnderlayGroup(acquired, protectionRule, policy.sequencePolicy, lower)
|
||||
sourceStart := len(input.sources)
|
||||
input.sources = append(input.sources, acquired.sources...)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if composition != nil {
|
||||
if assembly == nil {
|
||||
assembly = newAssemblyAccumulator(composition)
|
||||
} else {
|
||||
var declarations []sequenceDeclarationSpec
|
||||
for _, scope := range composition.acquisition {
|
||||
declarations = append(declarations, acquiredSequenceDeclarations(scope)...)
|
||||
}
|
||||
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
composition.root, composition.origins, composition.collectionPolicies, composition.applications, LayerUnderlay,
|
||||
request.sequencePolicy, declarations,
|
||||
_, diagnostics = applyCompletedTier(
|
||||
&assembly, composition, composition.collectionPolicies, LayerUnderlay, policy.sequencePolicy,
|
||||
input.sources[sourceStart:], input.versionState.evidenceSince(evidenceStart),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
projectTierSourceDestinations(input.sources[sourceStart:], input.versionState.evidenceSince(evidenceStart), operations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +131,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
lower = assembly.current.collectionPolicies
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
mainComposition, diagnostics = compositionForInputWithPolicies(input, lower, request.sequencePolicy)
|
||||
mainComposition, diagnostics = compositionForInputWithPolicies(input, lower, policy.sequencePolicy)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
@@ -168,24 +151,15 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
}
|
||||
if mainComposition != nil {
|
||||
input.versionState.locateMainVersion(mainComposition.root)
|
||||
if assembly == nil {
|
||||
assembly = newAssemblyAccumulator(mainComposition)
|
||||
} else {
|
||||
var declarations []sequenceDeclarationSpec
|
||||
for _, scope := range mainComposition.acquisition {
|
||||
declarations = append(declarations, acquiredSequenceDeclarations(scope)...)
|
||||
}
|
||||
operations, diagnostics := assembly.applyOverlayWithCollectionProjection(
|
||||
mainComposition.root, mainComposition.origins, mainComposition.collectionPolicies, mainComposition.applications, LayerBase,
|
||||
request.sequencePolicy, declarations,
|
||||
operations, diagnostics := applyCompletedTier(
|
||||
&assembly, mainComposition, mainComposition.collectionPolicies, LayerBase, policy.sequencePolicy,
|
||||
input.sources[:mainSourceCount], mainEvidence,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, append(versionDiagnostics, diagnostics...)
|
||||
}
|
||||
projectTierSourceDestinations(input.sources[:mainSourceCount], mainEvidence, operations)
|
||||
input.versionState.projectMainVersion(operations)
|
||||
}
|
||||
}
|
||||
if input.versionState != nil {
|
||||
request.effectiveVersion = input.versionState.effectiveRule
|
||||
}
|
||||
@@ -201,20 +175,33 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
if assembly == nil {
|
||||
return input, versionDiagnostics
|
||||
}
|
||||
assembly.current.inputBytes = accounting.bytes
|
||||
assembly.current.inputNodes = accounting.nodes
|
||||
assembly.current.inputBytes = state.accounting.bytes
|
||||
assembly.current.inputNodes = state.accounting.nodes
|
||||
if activeOverlay(request.overlay) {
|
||||
// The selected overlay context is independent of the main input. This
|
||||
// synthetic descriptor creates no source record or input contribution.
|
||||
selection := Source{kind: sourceBytes}
|
||||
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
|
||||
if request.overlayFilesystem == nil {
|
||||
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
||||
}
|
||||
resolved, diagnostic := resolveAbsentFilesystemContext(request.overlayFilesystem)
|
||||
if diagnostic != nil {
|
||||
return input, append(versionDiagnostics, *diagnostic)
|
||||
}
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
return input, append(versionDiagnostics, *diagnostic)
|
||||
}
|
||||
overlayRequest := selectedOverlayRequest{
|
||||
name: request.overlay, protectionRule: request.protectionVersion(),
|
||||
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
overlayPolicy := []sequencePolicy{request.sequencePolicy}
|
||||
input.sources, diagnostics = loadSelectedOverlayIntoWithVersion(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.protectionVersion(), defaultImportLimits(), input.versionState, overlayPolicy...)
|
||||
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||
if diagnostic != nil {
|
||||
return input, append(versionDiagnostics, *diagnostic)
|
||||
}
|
||||
input.sources, diagnostics = loadSelectedOverlay(overlayRequest, access, assembly, input.sources)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, append(versionDiagnostics, diagnostics...)
|
||||
}
|
||||
contexts, diagnostic := contextsForAcquiredSources(selection, request.overlayFilesystem, selected.source, input.sources[len(input.sources)-1:])
|
||||
contexts, diagnostic := contextsForResolvedSources(&resolved, input.sources[len(input.sources)-1:])
|
||||
if diagnostic != nil {
|
||||
return input, append(versionDiagnostics, *diagnostic)
|
||||
}
|
||||
@@ -225,10 +212,45 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, a
|
||||
return input, versionDiagnostics
|
||||
}
|
||||
|
||||
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, accounting *importAccounting, versionState *sourceVersionState, policy sequencePolicy) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||
initialBytes, initialNodes := accounting.bytes, accounting.nodes
|
||||
visited := make(map[resourceIdentity]importRoute)
|
||||
hostToken := &namespaceToken{}
|
||||
// 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) {
|
||||
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
|
||||
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
||||
nativeAnchors := make(map[*namespaceToken]string)
|
||||
for inputIndex, contribution := range group.inputs {
|
||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
||||
@@ -240,19 +262,10 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, a
|
||||
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
||||
inclusionGraph := newImportInclusionGraph()
|
||||
for inputIndex, contribution := range group.inputs {
|
||||
options := Options[struct{}]{
|
||||
Imports: contribution.imports, Filesystem: contribution.filesystem,
|
||||
Definition: Definition[struct{}]{Version: rule}, destination: contribution.destination.clone(),
|
||||
placementMode: contribution.placementMode, sequencePolicy: policy, deferAssembly: true,
|
||||
directiveContext: rootDirectiveContextLayered,
|
||||
importDefaults: func() importDefaults {
|
||||
if versionState == nil {
|
||||
return importDefaults{}
|
||||
}
|
||||
return snapshotImportDefaults(versionState.importDefaults)
|
||||
}(),
|
||||
}
|
||||
acquired, diagnostics := assembleBaseInputTrackedForRole(contribution.source, options, contribution.destination, accounting, visited, hostToken, versionState, sourceVersionContribution)
|
||||
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||
input: contribution, role: sourceVersionContribution,
|
||||
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
||||
}, policy, state, tier)
|
||||
for _, source := range acquired.sources {
|
||||
source.Layer = LayerUnderlay
|
||||
source.UnderlayIndex = index
|
||||
@@ -286,8 +299,8 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, a
|
||||
mergeImportInclusionGraph(&inclusionGraph, inputGraph)
|
||||
}
|
||||
acquiredGroup.importGraph = inclusionGraph.clone()
|
||||
acquiredGroup.inputBytes = accounting.bytes - initialBytes
|
||||
acquiredGroup.inputNodes = accounting.nodes - initialNodes
|
||||
acquiredGroup.inputBytes = state.accounting.bytes - initialBytes
|
||||
acquiredGroup.inputNodes = state.accounting.nodes - initialNodes
|
||||
return acquiredGroup, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
||||
})
|
||||
accounting := &importAccounting{}
|
||||
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, accounting, nil, sequencePolicy{})
|
||||
state := &acquisitionState{accounting: accounting}
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
||||
}
|
||||
@@ -108,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
||||
t.Fatalf("composed group handoff = %#v", composition)
|
||||
}
|
||||
|
||||
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, accounting, nil, sequencePolicy{})
|
||||
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
||||
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
||||
}
|
||||
@@ -160,8 +161,8 @@ func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
|
||||
request := loadRequest[map[string]any]{
|
||||
main: loadInput{source: NoSource()},
|
||||
underlays: []loadGroup{{inputs: []loadInput{
|
||||
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||
{source: File(zItem), 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"}}}},
|
||||
}
|
||||
@@ -204,8 +205,8 @@ func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
|
||||
request := loadRequest[map[string]any]{
|
||||
main: loadInput{source: NoSource()},
|
||||
underlays: []loadGroup{{inputs: []loadInput{
|
||||
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||
{source: File(zBad), imports: true, 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"}}}},
|
||||
}
|
||||
@@ -456,20 +457,26 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
|
||||
|
||||
accounting := &importAccounting{sources: maxImportSources - 1}
|
||||
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 {
|
||||
t.Fatalf("shared source budget = %#v", diagnostics)
|
||||
}
|
||||
accounting = &importAccounting{bytes: maxTotalBytes - 2}
|
||||
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 {
|
||||
t.Fatalf("shared byte budget = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, accounting, nil, sequencePolicy{})
|
||||
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||
state := &acquisitionState{accounting: accounting}
|
||||
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 {
|
||||
return nil, acquired.sources, acquired.contexts, diagnostics
|
||||
}
|
||||
@@ -518,13 +525,13 @@ func TestGroupedAccountingChargesMixedOccurrencesOnce(t *testing.T) {
|
||||
"child-two.yml": &fstest.MapFile{Data: []byte(child)},
|
||||
})
|
||||
accounting := &importAccounting{}
|
||||
first, sources, _, 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)) {
|
||||
t.Fatalf("first acquisition = %#v, %#v, %#v, accounting %#v", first, sources, diagnostics, accounting)
|
||||
first, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("one.yml"), imports: true}}}, 1, nil, accounting)
|
||||
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, accounting %#v", first, diagnostics, accounting)
|
||||
}
|
||||
second, sources, _, 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)) {
|
||||
t.Fatalf("second acquisition = %#v, %#v, %#v, accounting %#v", second, sources, diagnostics, accounting)
|
||||
second, diagnostics := testAcquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("two.yml"), imports: true}}}, 2, nil, accounting)
|
||||
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, accounting %#v", second, diagnostics, accounting)
|
||||
}
|
||||
var wantNodes int
|
||||
for _, text := range []string{one, child, two, child} {
|
||||
@@ -549,7 +556,7 @@ func TestGroupedGlobEntryBudgetIsShared(t *testing.T) {
|
||||
"parts/b.yml": &fstest.MapFile{Data: []byte("right: two\n")},
|
||||
})
|
||||
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 {
|
||||
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)
|
||||
if input.composition != nil {
|
||||
applications = cloneHistoryApplications(input.composition.applications)
|
||||
applications = input.composition.applications
|
||||
} else if input.document != nil {
|
||||
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
||||
}
|
||||
@@ -83,8 +83,8 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
return resolvedLoad[T]{
|
||||
configuration: configuration, decoded: decoded, metadata: metadata, final: final,
|
||||
snapshots: resolutionSnapshots, applications: applications,
|
||||
postDecodeApps: cloneHistoryApplications(prepared.postDecodeApps), structuralInput: document.Clone(prepared.structuralInput),
|
||||
preCLI: document.Clone(prepared.preCLI), preCLIOrigins: cloneOrigins(prepared.preCLIOrigins), referenceInput: referenceInput,
|
||||
postDecodeApps: prepared.postDecodeApps, structuralInput: document.Clone(prepared.structuralInput),
|
||||
preCLI: prepared.preCLI, preCLIOrigins: prepared.preCLIOrigins, referenceInput: referenceInput,
|
||||
versionEvidence: versionEvidence, inheritance: input.inheritance, contexts: input.contexts,
|
||||
}, diagnostics
|
||||
}
|
||||
@@ -99,7 +99,79 @@ func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, m
|
||||
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.
|
||||
// Detach without re-reading the environment or discarding validated state.
|
||||
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
|
||||
@@ -107,40 +179,23 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
||||
accumulator.add(*issue)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
decoded, metadata := resolved.decoded, resolved.metadata
|
||||
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||
structuralInput := document.Clone(resolved.structuralInput)
|
||||
preCLI := document.Clone(resolved.preCLI)
|
||||
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)
|
||||
decoded := resolved.decoded
|
||||
metadata, final, preCLI := inspection.metadata, inspection.final, inspection.preCLI
|
||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
|
||||
secrets := classifySecretPathsWithEvidence(schema, definition.Secret, metadata, final, applications, resolved.versionEvidence, decoded.baseline, resolved.structuralInput, resolved.preCLI, decoded.final)
|
||||
sources := sourceInfosWithVersionEvidence(accumulator.sources, resolved.versionEvidence, secrets, false)
|
||||
retainedTrees := make([]*document.Node, 0, len(allApplications)+5)
|
||||
retainedTrees = append(retainedTrees, decoded.final, final, structuralInput, preCLI)
|
||||
for _, application := range allApplications {
|
||||
retainedTrees := make([]*document.Node, 0, len(applications)+4)
|
||||
retainedTrees = append(retainedTrees, decoded.final, final, preCLI)
|
||||
for _, application := range applications {
|
||||
retainedTrees = append(retainedTrees, application.value)
|
||||
}
|
||||
inspection := &Inspection{
|
||||
metadata: metadata,
|
||||
sources: sources,
|
||||
contexts: cloneResourceContexts(resolved.contexts),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
input: decoded.final,
|
||||
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),
|
||||
}
|
||||
inspection.metadata = metadata
|
||||
inspection.sources = sources
|
||||
inspection.contexts = cloneResourceContexts(resolved.contexts)
|
||||
inspection.diagnostics = canonicalDiagnostics(accumulator.diagnostics)
|
||||
inspection.immutableLookup = buildDocumentLookup(retainedTrees...)
|
||||
inspection.secrets = secrets
|
||||
inspection.secretReferences = cloneSecretReferences(references)
|
||||
return &Loaded[T]{Config: configuration, Inspection: inspection}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,12 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
access := newNativeAccess(filesystem, "config", namespace.token)
|
||||
composed, sources, diagnostics := loadImportedWithAccess(access, "config/sub", source.path, parsed, nil, defaultImportLimits(), &importAccounting{})
|
||||
request := testImportRequest(source, nil, parsed, Path{}, nil, nil)
|
||||
policy := &acquisitionPolicy{importLimits: defaultImportLimits()}
|
||||
state := &acquisitionState{accounting: &importAccounting{}}
|
||||
tier := &tierAcquisitionState{}
|
||||
resolved := resolvedFilesystemContext{native: namespace, rootPath: "config", basePath: "config/sub", mainPath: source.path}
|
||||
composed, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 || sources[1].Path != "config/shared.yml" {
|
||||
t.Fatalf("contained imports = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
||||
}
|
||||
@@ -196,7 +201,12 @@ func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
_, _, diagnostics = loadImportedWithAccess(newNativeAccess(outside, "config", outsideNamespace.token), "config/sub", "config/sub/main.yml", parsed, nil, defaultImportLimits(), &importAccounting{})
|
||||
request = testImportRequest(filesystemFile(outsideNamespace, "config/sub/main.yml"), nil, parsed, Path{}, nil, nil)
|
||||
policy = &acquisitionPolicy{importLimits: defaultImportLimits()}
|
||||
state = &acquisitionState{accounting: &importAccounting{}}
|
||||
tier = &tierAcquisitionState{}
|
||||
resolved = resolvedFilesystemContext{native: outsideNamespace, rootPath: "config", basePath: "config/sub", mainPath: "config/sub/main.yml"}
|
||||
_, _, diagnostics = acquireImportedWithAccess(request, policy, state, tier, newNativeAccess(outside, "config", outsideNamespace.token), resolved)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("escaping import diagnostics = %#v", diagnostics)
|
||||
}
|
||||
@@ -240,18 +250,32 @@ type observedFilesystem struct {
|
||||
opened int
|
||||
closed int
|
||||
fallback bool
|
||||
failOpen string
|
||||
failRead 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) {
|
||||
if name == f.failOpen {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
inner, err := f.files.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.opened++
|
||||
file := &observedFile{File: inner, owner: f, name: name}
|
||||
f.handles = append(f.handles, file)
|
||||
if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback {
|
||||
return &observedDirectoryFile{observedFile: file, directory: directory}, nil
|
||||
}
|
||||
@@ -262,13 +286,27 @@ func (f *observedFilesystem) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if name == f.failDir {
|
||||
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) {
|
||||
t.Helper()
|
||||
if f.opened == 0 || f.opened != f.closed {
|
||||
t.Fatalf("filesystem opens=%d closes=%d", f.opened, f.closed)
|
||||
if f.opened != f.closed || len(f.handles) != f.opened {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +314,8 @@ type observedFile struct {
|
||||
fs.File
|
||||
owner *observedFilesystem
|
||||
name string
|
||||
closed bool
|
||||
closeCalls int
|
||||
}
|
||||
|
||||
func (f *observedFile) Read(data []byte) (int, error) {
|
||||
@@ -294,7 +334,16 @@ func (f *observedFile) Stat() (fs.FileInfo, error) {
|
||||
|
||||
func (f *observedFile) Close() error {
|
||||
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 {
|
||||
@@ -306,5 +355,15 @@ func (f *observedDirectoryFile) ReadDir(count int) ([]fs.DirEntry, error) {
|
||||
if f.name == f.owner.failDir {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
package engine
|
||||
|
||||
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
||||
if len(supplied) > 1 {
|
||||
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
|
||||
}
|
||||
var options Options[T]
|
||||
if len(supplied) == 1 {
|
||||
options = supplied[0]
|
||||
}
|
||||
options.importDefaults = snapshotImportDefaults(options.importDefaults)
|
||||
func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagnostic) {
|
||||
var diagnostics []Diagnostic
|
||||
if !options.importDefaultsValidated {
|
||||
diagnostics = validateImportDefaults(options.importDefaults)
|
||||
}
|
||||
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
|
||||
if input.placementMode != PlacementModeMapping && input.placementMode != PlacementModeSequenceItem {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
|
||||
}
|
||||
if normalized, err := normalizeDestination(options.destination); err != nil {
|
||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(options.destination))
|
||||
if normalized, err := normalizeDestination(input.destination); err != nil {
|
||||
diagnostics = append(diagnostics, invalidDestinationDiagnostic(input.destination))
|
||||
} else {
|
||||
options.destination = normalized
|
||||
input.destination = normalized
|
||||
}
|
||||
if options.placementMode == placementModeSequenceItem && len(options.destination.segments) == 0 {
|
||||
if input.placementMode == PlacementModeSequenceItem && len(input.destination.segments) == 0 {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "sequence-item placement requires a nonempty destination"))
|
||||
}
|
||||
definition := options.Definition
|
||||
if !validOverlayName(options.Overlay) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||
if input.filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(input.source, input.filesystem)...)
|
||||
}
|
||||
if options.Filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||
}
|
||||
if source.kind == sourceSearch {
|
||||
for _, issue := range source.searchIssues {
|
||||
if input.source.kind == sourceSearch {
|
||||
for _, issue := range input.source.searchIssues {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||
}
|
||||
if source.search == nil {
|
||||
if input.source.search == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "configuration search is invalid"))
|
||||
} else {
|
||||
diagnostics = append(diagnostics, validateSearch(*source.search, source.searchApp)...)
|
||||
diagnostics = append(diagnostics, validateSearch(*input.source.search, input.source.searchApp)...)
|
||||
}
|
||||
}
|
||||
if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||
}
|
||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||
if input.filesystem == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||
}
|
||||
return input, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func loadSettings[T any](defaults ImportDefaults, definition Definition[T], mode sourceVersionMode) (ImportDefaults, sequencePolicy, []Diagnostic) {
|
||||
defaults = snapshotImportDefaults(defaults)
|
||||
var diagnostics []Diagnostic
|
||||
diagnostics = append(diagnostics, validateImportDefaults(defaults)...)
|
||||
diagnostics = append(diagnostics, sourceVersionOptionDiagnostics(definition, mode)...)
|
||||
policy, policyDiagnostics := compileSequenceIdentityPolicy(definition.sequenceIdentityRegistrations)
|
||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||
return defaults, policy, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func sourceVersionOptionDiagnostics[T any](definition Definition[T], mode sourceVersionMode) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
if definition.Version != nil {
|
||||
if !definition.Version.Path.valid() {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
||||
@@ -55,12 +55,19 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||
}
|
||||
}
|
||||
if options.SourceVersionMode != sourceVersionModeDefault && options.SourceVersionMode != sourceVersionModeIfPresent && options.SourceVersionMode != sourceVersionModeRequired {
|
||||
if mode != sourceVersionModeDefault && mode != sourceVersionModeIfPresent && mode != sourceVersionModeRequired {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version mode is invalid"))
|
||||
} else if options.SourceVersionMode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
|
||||
} else if mode != sourceVersionModeDefault && (definition.Version == nil || !definition.Version.Path.valid() || definition.Version.Check == nil) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "source-version checking requires a valid version rule and checker"))
|
||||
}
|
||||
return options, canonicalDiagnostics(diagnostics)
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func overlayOptionDiagnostics(name string) []Diagnostic {
|
||||
if validOverlayName(name) {
|
||||
return nil
|
||||
}
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen")}
|
||||
}
|
||||
|
||||
func optionDiagnostic(code Code, message string) Diagnostic {
|
||||
|
||||
@@ -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 {
|
||||
return nil, invalidOverlayDocument()
|
||||
}
|
||||
mergeOriginContribution(a.current.origins, Path{}, incomingOrigin(incomingOrigins, Path{}, overlay, layer))
|
||||
sequences, diagnostics := mergeOverlayMappingsWithCollectionPolicy(a, overlay, incomingOrigins, incomingPolicies, layer, sequencePolicy, declarations)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
@@ -110,11 +109,39 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
sequencePolicy sequencePolicy,
|
||||
declarations []sequenceDeclarationSpec,
|
||||
) ([]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.append(overlay, Path{}, 0)
|
||||
identityFieldsByPath, resolvedPolicies, diagnostics := resolveStrictCollectionPolicies(
|
||||
claims.ordered, sequencePolicy, result.collectionPolicies, declarations,
|
||||
claims.ordered, sequencePolicy, lowerPolicies, declarations,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
@@ -128,26 +155,21 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
}
|
||||
registered := make(map[string]bool)
|
||||
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 _, ok := sequencePolicy.identityFieldsAt(claim.path); ok {
|
||||
registered[claim.path.String()] = true
|
||||
registeredPaths[claim.path.String()] = claim.path.clone()
|
||||
registered[key] = true
|
||||
registeredPaths[key] = claim.path.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
for key := range declarationsByPath {
|
||||
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))
|
||||
for key := range registered {
|
||||
@@ -167,65 +189,116 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
effectiveIdentityFields: cloneStrings(identityFieldsByPath[key]),
|
||||
}
|
||||
}
|
||||
return &preparedOverlayCollection{
|
||||
declarationsByPath: declarationsByPath,
|
||||
resolutions: resolutions,
|
||||
sequencePolicy: sequencePolicy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
sequenceApplications := make(map[string]sequenceHistoryContribution)
|
||||
policyValues := make(map[string]bool)
|
||||
var combinationDiagnostics []Diagnostic
|
||||
mergeOverlayMappingsWithHooks(result.root, overlay, Path{}, &assembly.index,
|
||||
recursiveMergeHooks{
|
||||
added: func(path Path, incoming *document.Node) {
|
||||
collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer)
|
||||
},
|
||||
combined: func(path Path, incoming *document.Node) {
|
||||
mergeOriginContribution(result.origins, path, incomingOrigin(incomingOrigins, path, incoming, layer))
|
||||
},
|
||||
replaced: func(path Path, existing, incoming *document.Node) {
|
||||
assembly.current.collectionPolicies.retireCollectionPoliciesAtOrBelow(path)
|
||||
recordOverlayReplacement(result, path, existing, incoming, layer, true, incomingOrigins)
|
||||
},
|
||||
merge: func(path Path, existing, incoming *document.Node) (*document.Node, bool) {
|
||||
resolution, active := resolutions[path.String()]
|
||||
// overlayMergeOperation owns recursive value application and its associated
|
||||
// origin, policy, diagnostic, and sequence-history evidence.
|
||||
type overlayMergeOperation struct {
|
||||
assembly *assemblyAccumulator
|
||||
result *composedDocument
|
||||
index *mappingIndex
|
||||
overlay *document.Node
|
||||
incomingOrigins map[string]Origin
|
||||
incomingPolicies collectionPolicyScope
|
||||
layer Layer
|
||||
prepared *preparedOverlayCollection
|
||||
diagnostics []Diagnostic
|
||||
sequenceApplications map[string]sequenceHistoryContribution
|
||||
valueDestinations map[string]bool
|
||||
}
|
||||
|
||||
func newOverlayMergeOperation(
|
||||
assembly *assemblyAccumulator,
|
||||
overlay *document.Node,
|
||||
incomingOrigins map[string]Origin,
|
||||
incomingPolicies collectionPolicyScope,
|
||||
layer Layer,
|
||||
prepared *preparedOverlayCollection,
|
||||
) *overlayMergeOperation {
|
||||
return &overlayMergeOperation{
|
||||
assembly: assembly, result: assembly.current, index: &assembly.index, overlay: overlay,
|
||||
incomingOrigins: incomingOrigins, incomingPolicies: incomingPolicies,
|
||||
layer: layer, prepared: prepared,
|
||||
sequenceApplications: make(map[string]sequenceHistoryContribution),
|
||||
valueDestinations: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (operation *overlayMergeOperation) apply() {
|
||||
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 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||
operation.diagnostics = append(operation.diagnostics, issues...)
|
||||
return existing, false
|
||||
}
|
||||
applyOverlaySequenceEvidence(result, path, existing, incoming, combined, resolution, incomingOrigins, incomingPolicies, layer)
|
||||
policyValues[path.String()] = true
|
||||
history := sequenceHistoryContribution{path: path.clone(), mode: sequenceHistoryMapped}
|
||||
if resolution.operation == sequenceModeReplace {
|
||||
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...)
|
||||
}
|
||||
sequenceApplications[path.String()] = history
|
||||
return combined.value, true
|
||||
},
|
||||
},
|
||||
applyOverlaySequenceEvidence(
|
||||
operation.assembly, path, existing, incoming, combined, resolution,
|
||||
operation.incomingOrigins, operation.incomingPolicies, operation.layer,
|
||||
)
|
||||
if len(combinationDiagnostics) != 0 {
|
||||
return nil, canonicalDiagnostics(combinationDiagnostics)
|
||||
}
|
||||
operation.valueDestinations[path.String()] = true
|
||||
operation.sequenceApplications[path.String()] = sequenceHistoryContributionForCombination(
|
||||
path, resolution.operation, incoming, combined,
|
||||
)
|
||||
return combined.value, true
|
||||
}
|
||||
|
||||
// Values are now complete. Index lazily for declaration-only checks;
|
||||
// this lookup is never retained across the value-changing merge above.
|
||||
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
|
||||
for key, grouped := range declarationsByPath {
|
||||
if policyValues[key] {
|
||||
for key, grouped := range prepared.declarationsByPath {
|
||||
if operation.valueDestinations[key] {
|
||||
continue
|
||||
}
|
||||
path := grouped[0].destination
|
||||
applicationIdentity, _ := sequencePolicy.identityFieldsAt(path)
|
||||
current, diagnostics := resolveCollectionPolicy(path, grouped, applicationIdentity, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, diagnostics...)
|
||||
applicationIdentity, _ := prepared.sequencePolicy.identityFieldsAt(path)
|
||||
current, issues := resolveCollectionPolicy(path, grouped, applicationIdentity, nil)
|
||||
if len(issues) != 0 {
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
continue
|
||||
}
|
||||
lower, found := assembly.current.collectionPolicies.at(path)
|
||||
@@ -239,28 +312,28 @@ func mergeOverlayMappingsWithCollectionPolicy(
|
||||
retained, _ := documentAtPathWithLookup(assembly.current.root, path, retainedLookup)
|
||||
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 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
policy, issues := collectionPolicyWithoutValue(lowerPolicy, current.policy, retained, path)
|
||||
if len(issues) != 0 {
|
||||
combinationDiagnostics = append(combinationDiagnostics, issues...)
|
||||
diagnostics = append(diagnostics, issues...)
|
||||
continue
|
||||
}
|
||||
assembly.current.collectionPolicies.set(path, policy)
|
||||
assembly.setCollectionPolicy(path, policy)
|
||||
}
|
||||
if len(combinationDiagnostics) != 0 {
|
||||
return nil, canonicalDiagnostics(combinationDiagnostics)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
keys := make([]string, 0, len(sequenceApplications))
|
||||
for key := range sequenceApplications {
|
||||
keys := make([]string, 0, len(operation.sequenceApplications))
|
||||
for key := range operation.sequenceApplications {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
sequences := make([]sequenceHistoryContribution, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
sequences = append(sequences, sequenceApplications[key])
|
||||
sequences = append(sequences, operation.sequenceApplications[key])
|
||||
}
|
||||
return sequences, nil
|
||||
}
|
||||
@@ -328,7 +401,7 @@ func recordOverlayReplacement(
|
||||
}
|
||||
|
||||
func applyOverlaySequenceEvidence(
|
||||
result *composedDocument,
|
||||
assembly *assemblyAccumulator,
|
||||
path Path,
|
||||
lower, higher *document.Node,
|
||||
combined sequenceCombinationResult,
|
||||
@@ -337,19 +410,20 @@ func applyOverlaySequenceEvidence(
|
||||
incomingPolicies collectionPolicyScope,
|
||||
layer Layer,
|
||||
) {
|
||||
result := assembly.current
|
||||
lowerPolicy, hadLowerPolicy := result.collectionPolicies.at(path)
|
||||
if resolution.operation == sequenceModeReplace {
|
||||
var lowerValue *collectionPolicy
|
||||
if hadLowerPolicy {
|
||||
lowerValue = &lowerPolicy
|
||||
}
|
||||
result.collectionPolicies.retireCollectionPoliciesAtOrBelow(path)
|
||||
assembly.retireCollectionPoliciesAtOrBelow(path)
|
||||
resolution.policy = collectionPolicyForReplacement(lowerValue, resolution.policy)
|
||||
}
|
||||
|
||||
if combined.value == nil || combined.value.Kind != document.Sequence {
|
||||
recordOverlayReplacement(result, path, lower, higher, layer, true, incomingOrigins)
|
||||
result.collectionPolicies.set(path, resolution.policy)
|
||||
assembly.setCollectionPolicy(path, resolution.policy)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,7 +507,7 @@ func applyOverlaySequenceEvidence(
|
||||
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) {
|
||||
|
||||
@@ -11,7 +11,6 @@ type postDecodeOverrideContext struct {
|
||||
decoded decodedConfiguration
|
||||
metadata Metadata
|
||||
resolution resolutionInput
|
||||
sources []SourceInfo
|
||||
applications []historyApplication
|
||||
postDecodeApps []historyApplication
|
||||
referenceInput *document.Node
|
||||
@@ -76,7 +75,6 @@ func preparePostDecodeOverrideContext(input assembledInput, before decodedConfig
|
||||
decoded: after,
|
||||
metadata: metadata,
|
||||
resolution: resolution,
|
||||
sources: sources,
|
||||
applications: applications,
|
||||
postDecodeApps: postDecodeApps,
|
||||
referenceInput: documentWithMetadataLocations(assigned.final, metadata, input.document),
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
||||
t.Fatalf("applyDecodedOverrides() issues = %#v", issues)
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
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" {
|
||||
t.Fatalf("resolution source contexts = %#v", prepared.resolution.contexts)
|
||||
}
|
||||
@@ -88,10 +85,10 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
||||
|
||||
inspection := Inspection{
|
||||
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,
|
||||
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.postDecodeApps[2].value, prepared.postDecodeApps[3].value),
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
// contributions alongside the effective typed value and its inspection data.
|
||||
type decodedOverrideResult struct {
|
||||
value reflect.Value
|
||||
preCLI *document.Node
|
||||
final *document.Node
|
||||
presence map[string]inputPresence
|
||||
origins map[string]Origin
|
||||
@@ -30,7 +29,6 @@ func applyDecodedOverrides(
|
||||
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())
|
||||
if cloneIssue != nil {
|
||||
return decodedOverrideResult{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)}
|
||||
@@ -76,7 +74,6 @@ func applyDecodedOverrides(
|
||||
|
||||
return decodedOverrideResult{
|
||||
value: working,
|
||||
preCLI: preCLI,
|
||||
final: final,
|
||||
presence: presence,
|
||||
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 {
|
||||
t.Fatalf("post-decode configuration = %#v", got)
|
||||
}
|
||||
if result.preCLI == result.final || !reflect.DeepEqual(result.preCLI, decoded.final) || len(result.overrides) != len(batch.overrides) {
|
||||
t.Fatalf("result snapshots/contributions = pre:%p final:%p overrides:%d", result.preCLI, result.final, len(result.overrides))
|
||||
if len(result.overrides) != len(batch.overrides) {
|
||||
t.Fatalf("result override contributions = %d, want %d", len(result.overrides), len(batch.overrides))
|
||||
}
|
||||
if _, explicit := result.presence["/items/0/enabled"]; explicit {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -39,89 +39,9 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
||||
|
||||
switch descriptor.kind {
|
||||
case descriptorStruct:
|
||||
result, issue := o.node(document.Mapping, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
|
||||
seen := make(map[string]bool, len(descriptor.fields))
|
||||
for _, member := range descriptor.members {
|
||||
memberValue := value.Field(member.index)
|
||||
if member.inline {
|
||||
if isNilValue(memberValue) {
|
||||
continue
|
||||
}
|
||||
inline, issue := o.project(memberValue, member.descriptor, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
if inline.Kind != document.Mapping {
|
||||
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
|
||||
}
|
||||
o.nodes-- // The flattened container is not retained in the output tree.
|
||||
for _, entry := range inline.Mapping {
|
||||
if seen[entry.Key] {
|
||||
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
|
||||
}
|
||||
seen[entry.Key] = true
|
||||
result.Mapping = append(result.Mapping, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
childPath := path.Key(member.name)
|
||||
if seen[member.name] {
|
||||
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
|
||||
}
|
||||
if issue := o.charge(childPath); issue != nil { // mapping key
|
||||
return nil, issue
|
||||
}
|
||||
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
seen[member.name] = true
|
||||
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
||||
}
|
||||
for _, entry := range result.Mapping {
|
||||
field := descriptor.inlineMap
|
||||
if index, ok := descriptor.fieldByName[entry.Key]; ok {
|
||||
field = &descriptor.fields[index]
|
||||
}
|
||||
if field != nil && field.secret {
|
||||
entry.Value.Sensitive = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return o.projectStruct(value, descriptor, path, depth)
|
||||
case descriptorMap:
|
||||
if value.IsNil() {
|
||||
return o.node(document.Null, path, depth)
|
||||
}
|
||||
leave, issue := o.enter(value, path)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
defer leave()
|
||||
result, issue := o.node(document.Mapping, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||
keys := value.MapKeys()
|
||||
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||
return cmp.Compare(left.String(), right.String())
|
||||
})
|
||||
for _, key := range keys {
|
||||
childPath := path.Key(key.String())
|
||||
if issue := o.charge(childPath); issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
|
||||
}
|
||||
return result, nil
|
||||
return o.projectMap(value, descriptor, path, depth)
|
||||
case descriptorSlice:
|
||||
if value.IsNil() {
|
||||
return o.node(document.Null, path, depth)
|
||||
@@ -131,22 +51,9 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
||||
return nil, issue
|
||||
}
|
||||
defer leave()
|
||||
fallthrough
|
||||
return o.projectSequence(value, descriptor, path, depth)
|
||||
case descriptorArray:
|
||||
result, issue := o.node(document.Sequence, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Sequence = make([]*document.Node, 0, value.Len())
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
childPath := path.Index(i)
|
||||
child, issue := o.project(value.Index(i), descriptor.element, childPath, depth+1)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Sequence = append(result.Sequence, child)
|
||||
}
|
||||
return result, nil
|
||||
return o.projectSequence(value, descriptor, path, depth)
|
||||
case descriptorDynamic:
|
||||
if value.IsNil() {
|
||||
return o.node(document.Null, path, depth)
|
||||
@@ -217,6 +124,119 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
||||
}
|
||||
}
|
||||
|
||||
func (o *valueOperation) projectStruct(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||
result, issue := o.node(document.Mapping, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
|
||||
seen := make(map[string]bool, len(descriptor.fields))
|
||||
for _, member := range descriptor.members {
|
||||
memberValue := value.Field(member.index)
|
||||
if member.inline {
|
||||
if isNilValue(memberValue) {
|
||||
continue
|
||||
}
|
||||
o.nodeCredits++
|
||||
inline, issue := o.project(memberValue, member.descriptor, path, depth)
|
||||
o.nodeCredits--
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
if inline.Kind != document.Mapping {
|
||||
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
|
||||
}
|
||||
o.nodes-- // The flattened container is not retained in the output tree.
|
||||
for _, entry := range inline.Mapping {
|
||||
if seen[entry.Key] {
|
||||
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
|
||||
}
|
||||
seen[entry.Key] = true
|
||||
result.Mapping = append(result.Mapping, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
childPath := path.Key(member.name)
|
||||
if seen[member.name] {
|
||||
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
|
||||
}
|
||||
if issue := o.charge(childPath); issue != nil { // mapping key
|
||||
return nil, issue
|
||||
}
|
||||
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
seen[member.name] = true
|
||||
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
||||
}
|
||||
for _, entry := range result.Mapping {
|
||||
field := descriptor.inlineMap
|
||||
if index, ok := descriptor.fieldByName[entry.Key]; ok {
|
||||
field = &descriptor.fields[index]
|
||||
}
|
||||
if field != nil && field.secret {
|
||||
entry.Value.Sensitive = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *valueOperation) projectMap(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||
if value.IsNil() {
|
||||
return o.node(document.Null, path, depth)
|
||||
}
|
||||
leave, issue := o.enter(value, path)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
defer leave()
|
||||
result, issue := o.node(document.Mapping, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
if issue := o.checkMinimumChildren(path, value.Len(), 2); issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||
keys := value.MapKeys()
|
||||
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||
return cmp.Compare(left.String(), right.String())
|
||||
})
|
||||
for _, key := range keys {
|
||||
childPath := path.Key(key.String())
|
||||
if issue := o.charge(childPath); issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *valueOperation) projectSequence(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||
result, issue := o.node(document.Sequence, path, depth)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
if issue := o.checkMinimumChildren(path, value.Len(), 1); issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Sequence = make([]*document.Node, 0, value.Len())
|
||||
for index := 0; index < value.Len(); index++ {
|
||||
childPath := path.Index(index)
|
||||
child, issue := o.project(value.Index(index), descriptor.element, childPath, depth+1)
|
||||
if issue != nil {
|
||||
return nil, issue
|
||||
}
|
||||
result.Sequence = append(result.Sequence, child)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||
if issue := o.checkDepth(path, depth); issue != nil {
|
||||
return nil, issue
|
||||
|
||||
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
|
||||
importSelections []importSelectionEvidence
|
||||
namespace *namespaceToken
|
||||
placementMode placementMode
|
||||
placementMode PlacementMode
|
||||
destinationUnresolved bool
|
||||
}
|
||||
|
||||
@@ -237,22 +237,6 @@ func SourceInfoImportSelectionsForFacade(source SourceInfo) []ImportSelectionFor
|
||||
|
||||
// SourceInfoWithImportSelections copies converted root-owned records into
|
||||
// private engine source evidence.
|
||||
func SourceInfoWithImportSelections(source SourceInfo, selections []ImportSelectionForFacade) SourceInfo {
|
||||
if selections == nil {
|
||||
source.importSelections = nil
|
||||
return source
|
||||
}
|
||||
source.importSelections = make([]importSelectionEvidence, len(selections))
|
||||
for index, selection := range selections {
|
||||
source.importSelections[index] = importSelectionEvidence{
|
||||
path: selection.Path.clone(), location: selection.Location,
|
||||
selector: importSelectorFromString(selection.Selector),
|
||||
outcome: importSelectionOutcomeFromString(selection.Outcome),
|
||||
}
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func importSelectorString(value importSelector) string {
|
||||
switch value {
|
||||
case importSelectorPath:
|
||||
@@ -266,19 +250,6 @@ func importSelectorString(value importSelector) string {
|
||||
}
|
||||
}
|
||||
|
||||
func importSelectorFromString(value string) importSelector {
|
||||
switch value {
|
||||
case "path":
|
||||
return importSelectorPath
|
||||
case "dir":
|
||||
return importSelectorDirectory
|
||||
case "glob":
|
||||
return importSelectorGlob
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func importSelectionOutcomeString(value importSelectionOutcome) string {
|
||||
if value == importSelectionNone {
|
||||
return "none"
|
||||
@@ -286,13 +257,6 @@ func importSelectionOutcomeString(value importSelectionOutcome) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func importSelectionOutcomeFromString(value string) importSelectionOutcome {
|
||||
if value == "none" {
|
||||
return importSelectionNone
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
||||
// root-owned numeric representation.
|
||||
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||
@@ -304,13 +268,6 @@ func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool {
|
||||
return source.destinationUnresolved
|
||||
}
|
||||
|
||||
// SourceInfoWithPlacement copies facade-owned source evidence into private fields.
|
||||
func SourceInfoWithPlacement(source SourceInfo, mode uint8, unresolved bool) SourceInfo {
|
||||
source.placementMode = placementMode(mode)
|
||||
source.destinationUnresolved = unresolved
|
||||
return source
|
||||
}
|
||||
|
||||
// SourceVersionCheck is a detached, public-safe record of a source-local
|
||||
// version declaration observed during an enabled load.
|
||||
type SourceVersionCheck struct {
|
||||
|
||||
@@ -287,8 +287,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
||||
records: make(map[string]*resolutionRecord),
|
||||
}
|
||||
context := &Resolution{state: state}
|
||||
err := resolver(configuration, context)
|
||||
state.active = false
|
||||
var err error
|
||||
func() {
|
||||
defer func() { state.active = false }()
|
||||
err = resolver(configuration, context)
|
||||
}()
|
||||
if err != nil && !hasErrors(state.diagnostics) {
|
||||
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user