Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 191015e289 | |||
| 7e53863adb | |||
| f7044cf695 | |||
| c2b3cb32da | |||
| 43ac34d10e | |||
| 795cc2f379 | |||
| e878ce51ff | |||
| ebf98ffc62 | |||
| 34c3241dae | |||
| 25382a112b | |||
| 95495ae7fc | |||
| 27be6bab23 | |||
| b066090db4 | |||
| 1d8438b635 | |||
| 3b10f97424 | |||
| c296eba6c1 | |||
| 0799e1d18f | |||
| 151b56e7ec | |||
| 4549fbaeef | |||
| c61597d137 | |||
| d120fe907b | |||
| 7f004f67ad | |||
| c0a0c753f9 | |||
| 40b00da175 | |||
| 1cee127300 | |||
| 9adcf14966 | |||
| 6b7a960d11 | |||
| 128bf475c3 |
65
docs/adr/0021-sequence-resolution-correspondence.md
Normal file
65
docs/adr/0021-sequence-resolution-correspondence.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR 0021: Match Registered Records During Resolution Inspection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-02
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Application resolution can change fields within sequences. Treating every
|
||||||
|
sequence change as atomic prevents a field record from covering an individual
|
||||||
|
edit. Registered collection identities also provide a way to relate a final
|
||||||
|
record to its entry value when positions change, so inspection can retain the
|
||||||
|
correct source evidence without confusing it with a prior index occupant.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Compare equal-length ordinary lists recursively by position during resolution
|
||||||
|
auditing. A length change requires coverage of the list or an ancestor. For a
|
||||||
|
collection with an applicable application identity registration, match entry and
|
||||||
|
final records only when both endpoints have valid, unique identities. A change
|
||||||
|
in identity order or membership requires list or ancestor coverage, while
|
||||||
|
surviving records retain their matched entry evidence. When matching is not
|
||||||
|
available, audit a changed list as a whole without introducing an early identity
|
||||||
|
failure. Final registration validation remains at its existing boundary.
|
||||||
|
|
||||||
|
Match only the resolution-entry and final snapshots, within the same registered
|
||||||
|
destination. Identity changes are removals and additions, not inferred renames.
|
||||||
|
Records and helpers address final positions; entry queries remain fixed to entry
|
||||||
|
coordinates, and helpers do not follow later moves automatically. A surviving
|
||||||
|
record takes precedence at a reused position; collection inspection retains
|
||||||
|
removed records' evidence, and absent historical positions remain inspectable.
|
||||||
|
New records do not inherit a previous occupant's history or explicitness.
|
||||||
|
|
||||||
|
Preserve recorded operations, source coordinates, sensitivity, and safe detached
|
||||||
|
inspection. Resolution comparison is separate from public effective diff, which
|
||||||
|
continues to present sequence changes atomically.
|
||||||
|
|
||||||
|
This extends resolution inspection under [ADR 0003](0003-preserve-presence-and-resolution-history.md)
|
||||||
|
while preserving the diff decision in [ADR 0010](0010-effective-configuration-diff.md),
|
||||||
|
source combination and identity rules in [ADR 0017](0017-collection-sequence-policy.md),
|
||||||
|
and lifecycle ordering in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep all sequence changes atomic during resolution.** This forces whole-list
|
||||||
|
records for independent field edits and does not identify a moved record's
|
||||||
|
earlier evidence.
|
||||||
|
- **Infer identities for every list.** Similar values or conventional field
|
||||||
|
names do not establish an application identity and could attach unrelated
|
||||||
|
history to a final record.
|
||||||
|
- **Reconstruct every intermediate callback mutation.** This requires a new
|
||||||
|
mutation history and implies an ordering that endpoint snapshots cannot prove.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Resolution auditing and inspection must share entry-to-final correspondence,
|
||||||
|
including explicit unmatched records, while preserving historical coordinates
|
||||||
|
and conservative sensitivity masking. Source assembly and public path types do
|
||||||
|
not change. The completed implementation plan is preserved in Git history at
|
||||||
|
`7e53863:docs/roadmap/implementation.md`; the
|
||||||
|
[configuration contract](../config.md) owns implemented behavior.
|
||||||
@@ -772,6 +772,15 @@ in history. Across tiers, history follows the selected
|
|||||||
whole-record upsert. Incoming contributions use their assigned positions, and
|
whole-record upsert. Incoming contributions use their assigned positions, and
|
||||||
replaced payload remains available as historical evidence.
|
replaced payload remains available as historical evidence.
|
||||||
|
|
||||||
|
During resolution, eligible application-registered records are matched between
|
||||||
|
entry and final snapshots by identity at the same collection destination.
|
||||||
|
Queries at a surviving final position follow that record's entry history, while
|
||||||
|
collection queries retain physical ordering and removed evidence. For example,
|
||||||
|
if `[reports, invoices]` becomes `[invoices]`, item 0 follows invoices' earlier
|
||||||
|
position, the collection retains reports' removal, and the absent old item 1
|
||||||
|
remains available through historical-position queries. An added identity does
|
||||||
|
not inherit the old occupant's history. Source locations stay source-local.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||||
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||||
@@ -1198,6 +1207,18 @@ records, helper targets, and unrecorded mutations fail the load. Equal-valued
|
|||||||
derived assignments may still record application intent even when there is no
|
derived assignments may still record application intent even when there is no
|
||||||
structural change.
|
structural change.
|
||||||
|
|
||||||
|
Resolution compares equal-length ordinary lists recursively by position, so an
|
||||||
|
edit to `/resources/0/path` can be recorded at that field. A length change needs
|
||||||
|
a list or ancestor record. For application-registered collections, valid unique
|
||||||
|
entry and final identities match records at the same destination; changing order
|
||||||
|
or membership still requires list or ancestor coverage. Invalid or duplicate
|
||||||
|
endpoint identities make a changed list atomic for auditing, without an early
|
||||||
|
identity error; final registration validation remains at its normal boundary.
|
||||||
|
YAML-only declarations do not provide this correspondence. Records and helper
|
||||||
|
targets use final positions, and helpers never follow a later move automatically.
|
||||||
|
Fixed entry queries continue to use entry positions. See
|
||||||
|
[ADR 0021](adr/0021-sequence-resolution-correspondence.md) for the decision.
|
||||||
|
|
||||||
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
|
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
|
||||||
locations. Reported errors fail the load; warnings survive on a successful result. A
|
locations. Reported errors fail the load; warnings survive on a successful result. A
|
||||||
returned resolver error adds one generic `resolve.hook_failed` error only when the
|
returned resolver error adds one generic `resolve.hook_failed` error only when the
|
||||||
@@ -1356,7 +1377,7 @@ Incompatible overlaps and invalid registrations produce `options.invalid` before
|
|||||||
source acquisition. Unused and root registrations are allowed and have no effect.
|
source acquisition. Unused and root registrations are allowed and have no effect.
|
||||||
|
|
||||||
Registrations provide identity for strict composition, sequence operations,
|
Registrations provide identity for strict composition, sequence operations,
|
||||||
inheritance lookup, and final validation. Dynamic selectors apply at concrete
|
inheritance lookup, resolution correspondence, and final validation. Dynamic selectors apply at concrete
|
||||||
strict-composition, inheritance, and final-result destinations without requiring
|
strict-composition, inheritance, and final-result destinations without requiring
|
||||||
the application to enumerate mapping keys. Each strict tier has independent
|
the application to enumerate mapping keys. Each strict tier has independent
|
||||||
duplicate-identity state among its peers; layered precedence and inheritance
|
duplicate-identity state among its peers; layered precedence and inheritance
|
||||||
@@ -2056,10 +2077,10 @@ rebuilt by unmarshaling their canonical text into fresh values, detaching privat
|
|||||||
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
|
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
|
||||||
|
|
||||||
Private structural comparison operates on canonical trees. Mapping additions,
|
Private structural comparison operates on canonical trees. Mapping additions,
|
||||||
removals, and descendant replacements retain semantic paths. A sequence is atomic:
|
removals, and descendant replacements retain semantic paths. The shared
|
||||||
any sequence change reports the sequence path and never invents correspondence
|
comparison used by effective diff treats sequences atomically. Resolution uses
|
||||||
between old and new indices. Cross-kind and null/container changes replace the
|
its separate positional and registered-identity comparison described above.
|
||||||
affected path.
|
Cross-kind and null/container changes replace the affected path.
|
||||||
|
|
||||||
These mechanisms support decoding, resolution auditing, metadata,
|
These mechanisms support decoding, resolution auditing, metadata,
|
||||||
and rendering. They are not a public generic conversion or cloning API.
|
and rendering. They are not a public generic conversion or cloning API.
|
||||||
|
|||||||
@@ -308,6 +308,66 @@ application choice, not a ConfigKit fallback: consumers can instead reject it or
|
|||||||
choose another explicit base. The example intentionally performs neither target
|
choose another explicit base. The example intentionally performs neither target
|
||||||
existence checks nor confinement checks.
|
existence checks nor confinement checks.
|
||||||
|
|
||||||
|
### Normalizing paths inside a list
|
||||||
|
|
||||||
|
Suppose `/srv/catalog/resources.yml` supplies this configuration, and the
|
||||||
|
application interprets file-authored relative paths against their declaring
|
||||||
|
file's directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
- name: reports
|
||||||
|
path: ./reports
|
||||||
|
- name: invoices
|
||||||
|
path: ./invoices
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same source-aware policy as `LoadNotariusPaths` to each `path` field
|
||||||
|
while the list remains in its entry order:
|
||||||
|
|
||||||
|
1. Build the field's semantic path, such as
|
||||||
|
`configkit.Keys("resources").Index(0).Key("path")`.
|
||||||
|
2. Query `Resolution.EntryInfo` at that path and use `Resolution.Source` to
|
||||||
|
identify its declaring file. Choose the base under the application's policy;
|
||||||
|
defaults, CLI values, and missing or composite provenance need their own
|
||||||
|
handling, as in the maintained example above.
|
||||||
|
3. Resolve the relative value against that base and use `Normalize` to assign
|
||||||
|
and record the individual field. For a mutation already performed by the
|
||||||
|
application, use `RecordNormalization` instead.
|
||||||
|
4. Continue without reordering the list. Later resource access uses the resolved
|
||||||
|
value rather than trying to recover a base directory from its effective origin.
|
||||||
|
|
||||||
|
For these file-authored values, the resulting configuration is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
- name: reports
|
||||||
|
path: /srv/catalog/reports
|
||||||
|
- name: invoices
|
||||||
|
path: /srv/catalog/invoices
|
||||||
|
```
|
||||||
|
|
||||||
|
Each changed `path` has a resolution origin, while its history retains the
|
||||||
|
earlier relative value and source evidence. Unchanged fields such as `name`
|
||||||
|
retain their source origins. Field-level recording avoids attributing the whole
|
||||||
|
list to the path-normalization operation; it does not preserve a file origin on
|
||||||
|
the normalized fields themselves.
|
||||||
|
|
||||||
|
If the application also restructures the list during resolution, a whole-list
|
||||||
|
record covers the structural change. For a collection registered with `name`
|
||||||
|
as its identity, history follows each surviving record through a reorder, but
|
||||||
|
descendants can acquire synthetic resolution origins without source locations.
|
||||||
|
Historical source evidence and effective origins answer different questions;
|
||||||
|
identity-aware history does not automatically select a resource base for a
|
||||||
|
derived value. Resolve source-relative values before moving records, and account
|
||||||
|
for final positions when recording helper targets. `EntryInfo` continues to use
|
||||||
|
entry positions even after a move.
|
||||||
|
|
||||||
|
See [resolution recording](../config.md#resolution-recording-and-failure-rules)
|
||||||
|
for helper and coverage behavior, and
|
||||||
|
[origins and historical paths](../config.md#presence-origins-and-historical-paths)
|
||||||
|
for the distinction between resolution origins and retained source evidence.
|
||||||
|
|
||||||
## Intentional migration changes
|
## Intentional migration changes
|
||||||
|
|
||||||
When upgrading, start with the changes relevant to your dependency and inputs:
|
When upgrading, start with the changes relevant to your dependency and inputs:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ Accepted decisions:
|
|||||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||||
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
||||||
|
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -92,6 +93,19 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
|||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
The completed field-level sequence resolution roadmap, implementation plan, and
|
||||||
|
resolved audit findings are preserved in Git history at `7e53863` under
|
||||||
|
`docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 7e53863:docs/roadmap/implementation.md
|
||||||
|
git show 7e53863:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The [configuration contract](config.md#recorded-application-resolution) owns
|
||||||
|
current resolution behavior, and [ADR 0021](adr/0021-sequence-resolution-correspondence.md)
|
||||||
|
records the correspondence decision.
|
||||||
|
|
||||||
The completed filesystem-source and underlay roadmap, original feature request,
|
The completed filesystem-source and underlay roadmap, original feature request,
|
||||||
implementation plan, and resolved remediation record are preserved in Git history
|
implementation plan, and resolved remediation record are preserved in Git history
|
||||||
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
||||||
@@ -250,15 +264,19 @@ dependency downloads. Acquire dependencies and any selected toolchain beforehand
|
|||||||
Format reported Go files with `gofmt -w <files>`.
|
Format reported Go files with `gofmt -w <files>`.
|
||||||
|
|
||||||
The default suite includes executable examples, maintained consumer fixtures,
|
The default suite includes executable examples, maintained consumer fixtures,
|
||||||
local documentation-link checks, and the dependency import-boundary check.
|
and the dependency/public boundary checks.
|
||||||
It must stay deterministic and runnable without network access or credentials.
|
It must stay deterministic and runnable without network access or credentials.
|
||||||
Still inspect new/untracked content and review paths, examples, ownership, and
|
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||||
current-versus-planned claims; automated checks do not replace content review.
|
current-versus-planned claims; automated checks do not replace content review.
|
||||||
|
|
||||||
Use focused checks while iterating, with validation proportionate to the change
|
Use focused checks while iterating, with validation proportionate to the change
|
||||||
under the testing policy. Documentation-only work normally needs the local-link
|
under the testing policy. Documentation-only work normally needs review of
|
||||||
check below and whitespace review; behavior-bearing examples and commands also
|
affected links and whitespace; behavior-bearing examples and commands also
|
||||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
need execution. Automated Markdown link checking runs against the committed
|
||||||
|
candidate through ReleaseKit, not during ordinary Go tests or the everyday
|
||||||
|
checker. See the [release procedure](release.md#4-publish-the-committed-candidate)
|
||||||
|
for the selected checks and optional diagnostic command. Use
|
||||||
|
`go test -race ./...` for ownership or concurrency changes.
|
||||||
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
||||||
its selected validation through one `publish` invocation. The everyday checker
|
its selected validation through one `publish` invocation. The everyday checker
|
||||||
remains a development gate and is not a separate release prerequisite. CI never
|
remains a development gate and is not a separate release prerequisite. CI never
|
||||||
@@ -285,6 +303,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||||
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
|
| Resolution sequence matching and inspection | [Correspondence](../internal/engine/resolution_correspondence_test.go), [auditing and history](../internal/engine/resolution_compare_test.go), [public integration](../facade_resolution_sequences_test.go) | `go test ./internal/engine -run 'Resolution\|History\|Explain\|Sensitivity'` and `go test . -run 'Resolution\|SequenceIdentity\|Diff'` |
|
||||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||||
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||||
@@ -293,7 +312,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||||
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
|
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
|
||||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
| Dependency and public API isolation | [Import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
|
|
||||||
## Fuzz validation
|
## Fuzz validation
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Implemented component owners:
|
|||||||
- Engine mechanism tests own source, composition, inheritance, decoding,
|
- Engine mechanism tests own source, composition, inheritance, decoding,
|
||||||
callback, provenance,
|
callback, provenance,
|
||||||
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
||||||
own public API compatibility, facade integration, documentation links, and
|
own public API compatibility, facade integration, and
|
||||||
dependency/public boundary checks. Consumer examples remain under
|
dependency/public boundary checks. Consumer examples remain under
|
||||||
`examples/consumers`.
|
`examples/consumers`.
|
||||||
|
|
||||||
@@ -161,6 +161,9 @@ before application resolution begins. The callback retains its separate fixed
|
|||||||
entry view; it does not read a builder that continues to change. After successful
|
entry view; it does not read a builder that continues to change. After successful
|
||||||
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
||||||
adds the original resolution records and their net replay groups. At the existing
|
adds the original resolution records and their net replay groups. At the existing
|
||||||
|
resolution boundary, [`resolution_correspondence.go`](../../internal/engine/resolution_correspondence.go)
|
||||||
|
matches eligible registered records and the shared history retains their paired
|
||||||
|
coordinates for selection and safe replay. At the existing
|
||||||
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
|
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
|
||||||
freezes that builder and counts the retained document roots before secret lookup
|
freezes that builder and counts the retained document roots before secret lookup
|
||||||
and validation.
|
and validation.
|
||||||
|
|||||||
@@ -376,6 +376,11 @@ it does not cover every intermediate callback state or later caller mutations.
|
|||||||
YAML-only declarations carry no such final guarantee through defaults, CLI
|
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||||
assignments, or application resolution.
|
assignments, or application resolution.
|
||||||
|
|
||||||
|
For resolution auditing and inspection, eligible registered collections match
|
||||||
|
entry and final records by identity within each destination. Structural changes
|
||||||
|
still require container coverage, and this correspondence does not change source
|
||||||
|
composition or move final validation earlier. Public diff remains sequence-atomic.
|
||||||
|
|
||||||
Preserve source attribution, presence, history, sensitivity, and resource context
|
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||||
available origin information without exposing identity values. The guarantee
|
available origin information without exposing identity values. The guarantee
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ test or CI run required before it.
|
|||||||
|
|
||||||
The selected profile runs the complete deterministic, offline default suite
|
The selected profile runs the complete deterministic, offline default suite
|
||||||
once with the race detector and test-time vet disabled. The suite already
|
once with the race detector and test-time vet disabled. The suite already
|
||||||
includes examples, consumer fixtures, documentation links, and package
|
includes examples, consumer fixtures, and package boundaries. One full vet
|
||||||
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
pass adds static checks beyond the test-time subset.
|
||||||
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||||
configurations distinct from native race compilation. macOS and newer-toolchain
|
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||||
checks are optional.
|
checks are optional.
|
||||||
@@ -231,6 +231,17 @@ delivery token path out of the committed candidate. The profile owns the path;
|
|||||||
the check does not inspect file contents or Git history. Hosted credential
|
the check does not inspect file contents or Git history. Hosted credential
|
||||||
preflight belongs to delivery and adds no source-publication gate.
|
preflight belongs to delivery and adds no source-publication gate.
|
||||||
|
|
||||||
|
ConfigKit delegates committed Markdown link validation to ReleaseKit rather
|
||||||
|
than maintaining a documentation-link test in the Go suite. Links outside the
|
||||||
|
checkout remain out of scope. This moves automated link feedback to candidate
|
||||||
|
validation; ordinary development review still covers edited links.
|
||||||
|
|
||||||
|
ConfigKit also selects ReleaseKit's local-replacement check for committed module
|
||||||
|
declarations. Filesystem replacements can make local validation succeed with
|
||||||
|
dependencies unavailable to consumers of the published module. Versioned module
|
||||||
|
replacements remain permitted. This check does not validate remote dependency
|
||||||
|
availability or require consumer retrieval.
|
||||||
|
|
||||||
ReleaseKit runs its selected Go commands with readonly module mode and
|
ReleaseKit runs its selected Go commands with readonly module mode and
|
||||||
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||||
validation. Formatting and whitespace remain ordinary development checks in
|
validation. Formatting and whitespace remain ordinary development checks in
|
||||||
|
|||||||
150
docs/release.md
150
docs/release.md
@@ -31,7 +31,8 @@ version supported for applications using ConfigKit.
|
|||||||
|
|
||||||
Release validation runs the offline Go test suite once with the race detector,
|
Release validation runs the offline Go test suite once with the race detector,
|
||||||
then a full vet pass and package compilation for both Linux targets with cgo
|
then a full vet pass and package compilation for both Linux targets with cgo
|
||||||
disabled (`CGO_ENABLED=0`). macOS and CI are
|
disabled (`CGO_ENABLED=0`), followed by the selected committed-file checks
|
||||||
|
described in [publication](#4-publish-the-committed-candidate). macOS and CI are
|
||||||
not release gates; do not wait for or poll CI.
|
not release gates; do not wait for or poll CI.
|
||||||
|
|
||||||
The vanity module path is the name applications put in Go imports. Source/tag
|
The vanity module path is the name applications put in Go imports. Source/tag
|
||||||
@@ -93,22 +94,18 @@ Prepare dependencies whether or not the minimum changes:
|
|||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
|
Run the module-selected ReleaseKit tool from the repository root. Go builds
|
||||||
the checkout.
|
and caches the executable; no temporary executable or launcher is needed.
|
||||||
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
These commands use the version recorded in `go.mod`:
|
||||||
commands never install another ReleaseKit version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
|
|
||||||
releasekit_bin="$releasekit_tmp/releasekit"
|
|
||||||
go build -o "$releasekit_bin" go.maximumdirect.net/releasekit/cmd/releasekit
|
|
||||||
go list -m go.maximumdirect.net/releasekit
|
go list -m go.maximumdirect.net/releasekit
|
||||||
"$releasekit_bin" version
|
go tool releasekit version
|
||||||
```
|
```
|
||||||
|
|
||||||
This repository selects ReleaseKit v0.6.0. Use the compiled program above for
|
This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the
|
||||||
publication so its exit status and publication report reach you unchanged.
|
tool's exit status and publication report; `go run` maps a nonzero child exit
|
||||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
status to 1. ReleaseKit's child Go commands
|
||||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||||
executable first on `PATH` through the publication command.
|
executable first on `PATH` through the publication command.
|
||||||
|
|
||||||
@@ -174,10 +171,10 @@ candidate to `main`, and invoke validation again.
|
|||||||
## 4. Publish the committed candidate
|
## 4. Publish the committed candidate
|
||||||
|
|
||||||
Once publication is authorized and the candidate is on the publication remote's
|
Once publication is authorized and the candidate is on the publication remote's
|
||||||
`main`, run the compiled ReleaseKit program once to validate and publish it:
|
`main`, run the module-selected ReleaseKit tool once to validate and publish it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
go tool releasekit publish vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the placeholder with the selected version. ReleaseKit checks the
|
Replace the placeholder with the selected version. ReleaseKit checks the
|
||||||
@@ -186,14 +183,20 @@ It verifies the candidate on
|
|||||||
`main`, and checks the release-note H1 for the product and version. It then
|
`main`, and checks the release-note H1 for the product and version. It then
|
||||||
runs the selected race-enabled standard suite once with test-time vet disabled,
|
runs the selected race-enabled standard suite once with test-time vet disabled,
|
||||||
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
||||||
arm64. It publishes only the annotated tag for that candidate. Confirmed
|
arm64. It also checks the committed candidate for the forbidden token path,
|
||||||
|
filesystem replacements in module declarations, and local Markdown destinations
|
||||||
|
and supported heading fragments. Markdown links outside the checkout are
|
||||||
|
reported as out of scope without failing validation. These file checks inspect
|
||||||
|
the committed candidate, not uncommitted or ignored working-tree content.
|
||||||
|
|
||||||
|
It publishes only the annotated tag for that candidate. Confirmed
|
||||||
publication of the intended tag completes the release. The tag points to the
|
publication of the intended tag completes the release. The tag points to the
|
||||||
captured commit and uses the complete checked-in note as its annotation message.
|
captured commit and uses the complete checked-in note as its annotation message.
|
||||||
Use the optional `check` command for diagnosis when useful. It validates without
|
Use the optional `check` command for diagnosis when useful. It validates without
|
||||||
publishing and is not required before `publish`, which performs its own checks:
|
publishing and is not required before `publish`, which performs its own checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
go tool releasekit check vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
For `publish`, interpret the program's exit status as follows:
|
For `publish`, interpret the program's exit status as follows:
|
||||||
@@ -215,11 +218,11 @@ remote read-back or consumer retrieval; those checks remain optional.
|
|||||||
## 5. Deliver the hosted release page
|
## 5. Deliver the hosted release page
|
||||||
|
|
||||||
After source publication succeeds, run local hosted delivery from the repository
|
After source publication succeeds, run local hosted delivery from the repository
|
||||||
root. Keep the worktree clean and `HEAD` at the published tag's commit. Reuse the
|
root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the
|
||||||
compiled executable from step 1. For the first release using this workflow:
|
module-selected tool as in step 1. For the first release using this workflow:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" deliver v1.0.0
|
go tool releasekit deliver v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
For subsequent releases, substitute the selected version. The profile selects
|
For subsequent releases, substitute the selected version. The profile selects
|
||||||
@@ -247,8 +250,8 @@ separately.
|
|||||||
A delivery problem leaves the successfully published source tag intact. Retry
|
A delivery problem leaves the successfully published source tag intact. Retry
|
||||||
delivery with the same immutable version after correcting the cause and
|
delivery with the same immutable version after correcting the cause and
|
||||||
resolving any uncertain write. For a later retry, use a clean checkout of the
|
resolving any uncertain write. For a later retry, use a clean checkout of the
|
||||||
original tag, fetching that tag if needed. Build the module-selected CLI from
|
original tag, fetching that tag if needed. Use that checkout's module-selected
|
||||||
that checkout using step 1's tool-build commands, without changing the tagged
|
CLI, prepare its dependencies and tool as in step 1 without changing the tagged
|
||||||
module files, and make the token available there. For example, with the tag
|
module files, and make the token available there. For example, with the tag
|
||||||
available locally:
|
available locally:
|
||||||
|
|
||||||
@@ -260,7 +263,7 @@ git switch --detach "$release_version"
|
|||||||
After preparing the tool in that tagged checkout:
|
After preparing the tool in that tagged checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" deliver "$release_version"
|
go tool releasekit deliver "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
|
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
|
||||||
@@ -269,53 +272,80 @@ version through the normal release procedure.
|
|||||||
|
|
||||||
## 6. Recover from a failed or uncertain tag push
|
## 6. Recover from a failed or uncertain tag push
|
||||||
|
|
||||||
The executable reports the publication state, phase, version, candidate commit,
|
ReleaseKit attempts to save publication evidence before pushing a tag and
|
||||||
tag-object ID, effective endpoint, and error on standard error. After a failed
|
reports the absolute record path, or a recording warning, separately from the
|
||||||
tag push, it makes one bounded check of the exact publication remote to find out
|
publication outcome. A recording failure does not undo a successful publication
|
||||||
whether the tag arrived. Exit status 3
|
or change its exit status. Confirmed publication still completes the source
|
||||||
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
release; hosted delivery remains a separate operation.
|
||||||
|
|
||||||
An annotated tag is a Git object containing the release note and a pointer to
|
Records default to a private store under the repository's Git common directory,
|
||||||
the source commit. Its tag-object ID identifies that complete object. Checking
|
shared by linked worktrees. `publish --record-dir DIR` can select another
|
||||||
only the commit ID would not establish that the intended tag object arrived.
|
location; relative paths resolve from the source repository root. Records can
|
||||||
|
contain exact endpoints, including credentials. Keep them private and remove
|
||||||
|
them when their recovery value expires. There is no new tracked storage,
|
||||||
|
credential-preflight gate, or required recording-success check.
|
||||||
|
|
||||||
For an uncertain result, use the exact endpoint (remote URL), candidate commit,
|
After a failed push, ReleaseKit makes one bounded observation of the original
|
||||||
version, and tag-object ID from the report. Copy the captured endpoint into the variable
|
publication endpoint. If the outcome remains unknown, use the explicitly saved
|
||||||
below using normal shell quoting. Check that Git still resolves it to itself,
|
record rather than rerunning publication. Copy the recovery command printed by
|
||||||
then query the authoritative refs once:
|
ReleaseKit, or use the module-selected tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
publication_record='/absolute/path/from/the/publication/report.json'
|
||||||
|
go tool releasekit reconcile --record "$publication_record"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the path was lost, discover local attempts without contacting a remote:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go tool releasekit publication-attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
Select the record for the intended attempt from its reported version, candidate,
|
||||||
|
and tag evidence; discovery does not automatically choose the latest attempt.
|
||||||
|
For an alternate store, supply `publication-attempts --record-dir DIR`.
|
||||||
|
Both commands support optional `--json` output. Treat saved records as private
|
||||||
|
regardless of the CLI's credential-redacted diagnostic output.
|
||||||
|
|
||||||
|
Reconciliation observes the captured endpoint and exact tag object using current
|
||||||
|
supported Git transport settings. It does not load the current profile, require
|
||||||
|
the original `HEAD`, rerun candidate validation, change tags, or contact Gitea
|
||||||
|
hosted delivery. It needs no clean candidate checkout. Use ReleaseKit v0.8.0 or
|
||||||
|
another CLI supporting the saved record format, including format 2 where exact
|
||||||
|
non-UTF-8 endpoint/path bytes were retained.
|
||||||
|
|
||||||
|
| Reconciliation status | Meaning and next action |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | The intended tag object is published. The source release is complete; continue with hosted delivery from its clean tagged checkout. |
|
||||||
|
| `1` | The tag is currently absent or conflicting. Read the reported state. Investigate conflicts; absence alone does not prove that the version was never published. |
|
||||||
|
| `2` | The record cannot be read/decoded, or the invocation is invalid. Correct the path, access, CLI compatibility, or command. |
|
||||||
|
| `3` | Observation remains unresolved, including insufficient evidence or cancellation. Correct the transport or other reported cause before another explicit observation; do not blindly republish. |
|
||||||
|
|
||||||
|
These are recovery actions after failure, not read-back requirements after a
|
||||||
|
successful push. A failed observation leaves the result unresolved. Do not poll
|
||||||
|
CI or move a tag to settle uncertainty.
|
||||||
|
|
||||||
|
When no usable record exists (for example, storage failed or an older CLI made
|
||||||
|
the attempt), use the captured endpoint and exact tag-object ID from the original
|
||||||
|
report for a manual authoritative observation. Checking only the peeled commit
|
||||||
|
would not establish that the intended annotated tag object arrived. Do not
|
||||||
|
substitute current profile or local tag state for missing original evidence.
|
||||||
|
|
||||||
|
Only after establishing that a retained local tag was never published or used
|
||||||
|
by another release channel may it be removed before retrying publication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
authoritative='COPY_CAPTURED_ENDPOINT'
|
|
||||||
release_version='vMAJOR.MINOR.PATCH'
|
release_version='vMAJOR.MINOR.PATCH'
|
||||||
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
|
|
||||||
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
|
|
||||||
```
|
|
||||||
|
|
||||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
|
||||||
Correct transport configuration before continuing; a rewritten destination
|
|
||||||
cannot establish absence at the captured endpoint.
|
|
||||||
|
|
||||||
Compare the `main` object ID with the reported candidate commit and the tag
|
|
||||||
object ID with the reported tag-object ID. `--refs` returns the annotated tag
|
|
||||||
object itself. A peeled commit is the source commit reached by following that
|
|
||||||
tag; it is a different identity. If the tag object matches, the
|
|
||||||
release is published. If the query fails, the result is conflicting, or the
|
|
||||||
endpoint or candidate differs, stop and resolve the state before retrying. If
|
|
||||||
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
|
||||||
the reported candidate, remove a retained local tag only after confirming it
|
|
||||||
was not published or used by another channel; then rerun `publish`, which
|
|
||||||
revalidates the candidate:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag -d "$release_version"
|
git tag -d "$release_version"
|
||||||
"$releasekit_bin" publish "$release_version"
|
go tool releasekit publish "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Replace the placeholder with the original attempt's version.
|
||||||
|
The next `publish` revalidates the candidate and publication guards. A conflicting
|
||||||
|
published tag must never be moved or reused. A defect in published source
|
||||||
|
requires a corrected commit, a new appropriate version, and a matching note.
|
||||||
A validation failure after the candidate reached `main` leaves an untagged
|
A validation failure after the candidate reached `main` leaves an untagged
|
||||||
candidate there. Correct it with a new commit rather than rewriting or rolling
|
candidate there; correct it in a new commit rather than rewriting `main`.
|
||||||
back `main`. A conflicting published tag must never be moved or reused. A
|
|
||||||
defect in published source requires a corrected commit, a new semantically
|
|
||||||
appropriate version, and a matching release note.
|
|
||||||
|
|
||||||
## 7. Optional consumer verification
|
## 7. Optional consumer verification
|
||||||
|
|
||||||
|
|||||||
79
docs/releases/v1.1.0.md
Normal file
79
docs/releases/v1.1.0.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# ConfigKit v1.1.0
|
||||||
|
|
||||||
|
ConfigKit now accepts field-level resolution records inside lists and follows
|
||||||
|
registered records across position changes when reporting their provenance and
|
||||||
|
history. This release also standardizes more release validation on ReleaseKit.
|
||||||
|
|
||||||
|
## Field-level resolution in lists
|
||||||
|
|
||||||
|
Applications can normalize or derive a field inside a list and record that
|
||||||
|
field directly. For example, changing `/resources/0/path` can be covered by
|
||||||
|
`RecordNormalization` at that path, without recording the entire `/resources`
|
||||||
|
list when no other changes require coverage.
|
||||||
|
|
||||||
|
For ordinary lists of equal length, resolution auditing compares corresponding
|
||||||
|
positions recursively. Positions are addresses; ConfigKit does not infer an
|
||||||
|
element's identity from similar values. A length change still requires a record
|
||||||
|
for the list or an ancestor.
|
||||||
|
|
||||||
|
For collections registered through `Definition.SequenceIdentities`, ConfigKit
|
||||||
|
matches records between the resolution-entry and final snapshots when both
|
||||||
|
endpoints have valid, unique identities. Field edits can be recorded individually.
|
||||||
|
Changes to identity order or membership still require list or ancestor coverage;
|
||||||
|
changing an identity is treated as removal and addition. When matching is
|
||||||
|
unavailable, a changed registered collection is audited as a whole. Existing
|
||||||
|
final identity validation remains in place, allowing resolution to repair entry
|
||||||
|
values.
|
||||||
|
|
||||||
|
See [recorded application resolution](../config.md#recorded-application-resolution)
|
||||||
|
for the complete rules.
|
||||||
|
|
||||||
|
## Matched history and inspection
|
||||||
|
|
||||||
|
For eligible registered collections, inspection at a surviving record's final
|
||||||
|
position follows its own entry evidence, including earlier values, input
|
||||||
|
explicitness, and source attribution. A record no longer inherits the evidence
|
||||||
|
of a different record that previously occupied its final position. Original
|
||||||
|
source coordinates remain intact, while reported operation paths follow the
|
||||||
|
matched final record.
|
||||||
|
|
||||||
|
Collection inspection retains removed records' evidence. Removed fields of
|
||||||
|
surviving moved records remain available through metadata history and
|
||||||
|
explanation, including deep inherited fields. Sensitivity propagation protects
|
||||||
|
matched and removed evidence and recorded dependencies; detached before/after
|
||||||
|
snapshots remain conservatively masked.
|
||||||
|
|
||||||
|
Correspondence lookup, sensitivity propagation, and resolution-change ordering
|
||||||
|
reuse indexes, a work queue, and prepared pointer strings to avoid repeated
|
||||||
|
prefix construction, full-map passes, and formatting during sorting. No
|
||||||
|
end-to-end performance improvement is claimed.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible extension of resolution auditing and inspection.
|
||||||
|
Existing callbacks that record a whole list remain supported. Records and helper
|
||||||
|
targets for surviving values refer to final positions; helpers do not follow
|
||||||
|
later moves automatically. `Resolution.EntryInfo` and `Resolution.IsExplicit`
|
||||||
|
retain their fixed entry-snapshot meaning.
|
||||||
|
|
||||||
|
Public effective-configuration diffs continue to report list changes atomically.
|
||||||
|
Source composition, collection merge operations, configuration syntax, and public
|
||||||
|
path addressing are unchanged. No new public Go symbols are required.
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.27.0, and the YAML dependency is unchanged.
|
||||||
|
Linux amd64 and arm64 remain release-validated; macOS remains best-effort.
|
||||||
|
|
||||||
|
## Release tooling
|
||||||
|
|
||||||
|
ReleaseKit advances from v0.6.0 to v0.8.0. The runbook uses the module-selected
|
||||||
|
`go tool releasekit` command and documents saved publication attempts and
|
||||||
|
reconciliation after failed or uncertain tag pushes.
|
||||||
|
|
||||||
|
ReleaseKit now checks committed Markdown links and rejects filesystem dependency
|
||||||
|
replacements during candidate validation. The custom documentation-link Go test
|
||||||
|
is removed, so automated link feedback occurs during candidate validation rather
|
||||||
|
than ordinary Go tests. Source publication and hosted Gitea delivery remain
|
||||||
|
separate outcomes; CI remains outside release gates. See the
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/oss/attribution.md)
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
|
|
||||||
|
|
||||||
func TestLocalDocumentationLinks(t *testing.T) {
|
|
||||||
repository, err := filepath.Abs(".")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var failures []string
|
|
||||||
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if entry.IsDir() {
|
|
||||||
if entry.Name() == ".git" {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if filepath.Ext(path) != ".md" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, readErr := os.ReadFile(path)
|
|
||||||
if readErr != nil {
|
|
||||||
return readErr
|
|
||||||
}
|
|
||||||
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
|
|
||||||
target := strings.Trim(match[1], "<>")
|
|
||||||
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filePart, fragment, _ := strings.Cut(target, "#")
|
|
||||||
decoded, decodeErr := url.PathUnescape(filePart)
|
|
||||||
if decodeErr != nil {
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
linked := path
|
|
||||||
if decoded != "" {
|
|
||||||
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
|
|
||||||
}
|
|
||||||
_, statErr := os.Stat(linked)
|
|
||||||
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
|
|
||||||
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
|
|
||||||
continue // Recorded sibling-source links are optional in isolated clones.
|
|
||||||
}
|
|
||||||
if statErr != nil {
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(failures) != 0 {
|
|
||||||
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func optionalSiblingSource(repository, linked string) bool {
|
|
||||||
relative, err := filepath.Rel(filepath.Dir(repository), linked)
|
|
||||||
if err != nil || relative == "." || filepath.IsAbs(relative) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
parts := strings.Split(filepath.ToSlash(relative), "/")
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch strings.ToLower(parts[0]) {
|
|
||||||
case "narratio", "notarius", "weatherreporter", "seriatim":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownHasFragment(path, wanted string) bool {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
wanted, err = url.PathUnescape(wanted)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen := make(map[string]int)
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if !strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
|
||||||
base := markdownFragment(heading)
|
|
||||||
count := seen[base]
|
|
||||||
seen[base]++
|
|
||||||
fragment := base
|
|
||||||
if count > 0 {
|
|
||||||
fragment = fmt.Sprintf("%s-%d", base, count)
|
|
||||||
}
|
|
||||||
if fragment == wanted {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownFragment(heading string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
previousSpace := false
|
|
||||||
for _, character := range strings.ToLower(heading) {
|
|
||||||
switch {
|
|
||||||
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
|
|
||||||
result.WriteRune(character)
|
|
||||||
previousSpace = false
|
|
||||||
case unicode.IsSpace(character):
|
|
||||||
if result.Len() > 0 && !previousSpace {
|
|
||||||
result.WriteByte('-')
|
|
||||||
previousSpace = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimRight(result.String(), "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
func relativePath(root, path string) string {
|
|
||||||
relative, err := filepath.Rel(root, path)
|
|
||||||
if err != nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return filepath.ToSlash(relative)
|
|
||||||
}
|
|
||||||
223
facade_resolution_sequences_test.go
Normal file
223
facade_resolution_sequences_test.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolutionSequenceItem struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type resolutionSequenceConfig struct {
|
||||||
|
Groups map[string][]resolutionSequenceItem `yaml:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicResolutionOrdinaryListField(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []resolutionSequenceItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error {
|
||||||
|
configkit.Normalize(r, configkit.Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize")
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: old}]\n")), configkit.Options[config]{Definition: definition})
|
||||||
|
if err != nil || result.Config.Items[0].Value != "changed" {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRegisteredResolutionHistoryAndOwnership(t *testing.T) {
|
||||||
|
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("groups"), configkit.SequenceAnyMappingKey()}
|
||||||
|
path := configkit.Keys("groups", "active")
|
||||||
|
entryFixed := false
|
||||||
|
definition := configkit.Definition[resolutionSequenceConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityMatching(selector, "id")},
|
||||||
|
Resolve: func(value *resolutionSequenceConfig, r *configkit.Resolution) error {
|
||||||
|
entryFixed = r.IsExplicit(path.Index(0).Key("value")) && !r.IsExplicit(path.Index(1).Key("value"))
|
||||||
|
items := value.Groups["active"]
|
||||||
|
items[0], items[1] = items[1], items[0]
|
||||||
|
items[1].Value = "normalized"
|
||||||
|
value.Groups["active"] = items
|
||||||
|
r.RecordNormalization(path, "reorder")
|
||||||
|
r.RecordNormalization(path.Index(1).Key("value"), "normalize")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
source := configkit.Bytes("records", []byte("groups:\n active:\n - id: a\n value: original\n - id: b\n"))
|
||||||
|
load := func() *configkit.Result[resolutionSequenceConfig] {
|
||||||
|
result, err := configkit.Load(source, configkit.Options[resolutionSequenceConfig]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result := load()
|
||||||
|
if !entryFixed || result.Config.Groups["active"][1].Value != "normalized" {
|
||||||
|
t.Fatalf("result = %#v", result.Config)
|
||||||
|
}
|
||||||
|
info, ok := result.Metadata().Lookup(path.Index(1).Key("value"))
|
||||||
|
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerResolution {
|
||||||
|
t.Fatalf("metadata = %#v, %t", info, ok)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(path.Index(1).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "normalized\n" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
other := load()
|
||||||
|
result.Config.Groups["active"][1].Value = "mutated"
|
||||||
|
if len(explanation.History) != 0 {
|
||||||
|
explanation.History[0].After.YAML = "mutated"
|
||||||
|
}
|
||||||
|
again, _ := result.Explain(path.Index(1).Key("value"))
|
||||||
|
if strings.Contains(again.Effective.YAML, "mutated") || other.Config.Groups["active"][1].Value != "normalized" {
|
||||||
|
t.Fatal("inspection or independent load changed")
|
||||||
|
}
|
||||||
|
plain, err := configkit.Load[resolutionSequenceConfig](source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
differences, err := configkit.Diff(plain, result)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/groups/active" {
|
||||||
|
t.Fatalf("diff = %#v, %v", differences, err)
|
||||||
|
}
|
||||||
|
layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[resolutionSequenceConfig]{Definition: definition})
|
||||||
|
if err != nil || layered.Config.Groups["active"][1].Value != other.Config.Groups["active"][1].Value {
|
||||||
|
t.Fatalf("layered = %#v, %v", layered, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRegisteredMoveRequiresContainerRecord(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []resolutionSequenceItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a}, {id: b}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Resolve: func(value *config, r *configkit.Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(configkit.Keys("items").Index(1).Key("id"), "record field")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeResolveUnrecordedChange && diagnostic.Path.String() == "/items" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRegisteredRemovalKeepsSurvivorHistory(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []resolutionSequenceItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Resolve: func(value *config, r *configkit.Resolution) error {
|
||||||
|
value.Items = value.Items[1:]
|
||||||
|
r.RecordNormalization(configkit.Keys("items"), "remove first")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("items", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), configkit.Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing survivor")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == configkit.LayerMain && change.After.YAML == "report\n" {
|
||||||
|
t.Fatalf("wrong lineage = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container, ok := result.Explain(configkit.Keys("items"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing collection explanation")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range container.History {
|
||||||
|
if change.Origin.Layer == configkit.LayerResolution && strings.Contains(change.Before.YAML, "reports") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing removal evidence = %#v", container.History)
|
||||||
|
}
|
||||||
|
if _, ok := result.Explain(configkit.Keys("items").Index(1)); !ok {
|
||||||
|
t.Fatal("missing historical position")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicYAMLIdentityRemainsPositionalDuringResolution(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []resolutionSequenceItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("items", []byte("config: {sequences: [{destination: [items], key: id}]}\nitems: [{id: a, value: first}, {id: b, value: second}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(configkit.Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "first\n" && change.After.YAML == "second\n" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("positional history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicMovedSensitiveRecordIsHidden(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []resolutionSequenceItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: FACADE_PRIVATE_CANARY}, {id: b, value: public}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/value" },
|
||||||
|
Resolve: func(value *config, r *configkit.Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(configkit.Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []configkit.Path{configkit.Keys("items"), configkit.Keys("items").Index(1).Key("value")} {
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing explanation %s", path.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "FACADE_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("effective leak")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "FACADE_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "FACADE_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("history leak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
go.maximumdirect.net/releasekit v0.6.0 // indirect
|
go.maximumdirect.net/releasekit v0.8.0 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
|||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.maximumdirect.net/releasekit v0.6.0 h1:wbzMeQGeLjXigBSN9j9iJLf/CC3htjzt9iEwubezgy0=
|
go.maximumdirect.net/releasekit v0.8.0 h1:hSaHPbUI7tAhnolfbu7UMj2fvmm9FGBY+jhkygdbxR0=
|
||||||
go.maximumdirect.net/releasekit v0.6.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
go.maximumdirect.net/releasekit v0.8.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type Explanation struct {
|
|||||||
// or historical path, hiding sensitive information. History follows processing
|
// or historical path, hiding sensitive information. History follows processing
|
||||||
// order: inheritance appears after source combination and before the transition
|
// order: inheritance appears after source combination and before the transition
|
||||||
// to typed values. Unknown and malformed paths return false.
|
// to typed values. Unknown and malformed paths return false.
|
||||||
|
// Surviving registered records use their matched resolution-entry history.
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"go.maximumdirect.net/configkit/internal/document"
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
type documentDeltaKind uint8
|
type documentDeltaKind uint8
|
||||||
@@ -24,6 +25,10 @@ type documentDelta struct {
|
|||||||
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||||
var deltas []documentDelta
|
var deltas []documentDelta
|
||||||
compareConfigurationValue(before, after, Path{}, &deltas)
|
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||||
|
return sortDocumentDeltas(deltas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortDocumentDeltas(deltas []documentDelta) []documentDelta {
|
||||||
type orderedDelta struct {
|
type orderedDelta struct {
|
||||||
delta documentDelta
|
delta documentDelta
|
||||||
pointer string
|
pointer string
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
}
|
}
|
||||||
selections := model.indexReplaySelections(selected)
|
selections := model.indexReplaySelections(selected)
|
||||||
|
coordinate := model.queryCoordinates(path)
|
||||||
|
entryPath := coordinate.entry
|
||||||
|
if !coordinate.hasEntry {
|
||||||
|
entryPath = Path{invalid: true}
|
||||||
|
}
|
||||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)}
|
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)}
|
||||||
if info, ok := r.metadata.Lookup(path); ok {
|
if info, ok := r.metadata.Lookup(path); ok {
|
||||||
explanation.Origin = cloneOrigin(info.Origin)
|
explanation.Origin = cloneOrigin(info.Origin)
|
||||||
@@ -57,7 +62,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
working := document.Clone(model.baseline)
|
working := document.Clone(model.baseline)
|
||||||
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
|
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
After: valueSnapshot(working, path, r.secrets),
|
After: valueSnapshot(working, entryPath, r.secrets),
|
||||||
Origin: cloneOrigin(baseline.attribution.origin),
|
Origin: cloneOrigin(baseline.attribution.origin),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,13 +72,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
application := payload.application
|
application := payload.application
|
||||||
before := valueSnapshot(working, path, r.secrets)
|
before := valueSnapshot(working, entryPath, r.secrets)
|
||||||
applyHistoryApplication(&working, application, replayIndex)
|
applyHistoryApplication(&working, application, replayIndex)
|
||||||
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
|
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
|
||||||
if !relevant {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
after := valueSnapshot(working, entryPath, r.secrets)
|
||||||
if !before.Present && !after.Present {
|
if !before.Present && !after.Present {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -83,20 +88,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
Origin: cloneOrigin(selected.attribution.origin),
|
Origin: cloneOrigin(selected.attribution.origin),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
appendInheritanceExplanation(&explanation, &working, path, model, selections, r.secrets)
|
appendInheritanceExplanation(&explanation, &working, entryPath, model, selections, r.secrets)
|
||||||
|
|
||||||
// Intermediate input replacements do not permanently suppress defaults:
|
// Intermediate input replacements do not permanently suppress defaults:
|
||||||
// decoding applies them to the final input tree. Show that reconciliation
|
// decoding applies them to the final input tree. Show that reconciliation
|
||||||
// explicitly before typed CLI contributions and domain resolution.
|
// explicitly before typed CLI contributions and domain resolution.
|
||||||
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
|
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
|
||||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
|
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, entryPath)
|
||||||
decoded, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
decoded, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
|
||||||
if selected && !configurationValuesEqual(replayed, decoded) {
|
if selected && !configurationValuesEqual(replayed, decoded) {
|
||||||
origin, reasons := checkpointExplanationAttribution(selection)
|
origin, reasons := checkpointExplanationAttribution(selection)
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
Before: valueSnapshot(working, entryPath, r.secrets),
|
||||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
Reasons: reasons,
|
Reasons: reasons,
|
||||||
})
|
})
|
||||||
@@ -109,13 +114,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
|
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
|
||||||
before := valueSnapshot(working, path, r.secrets)
|
before := valueSnapshot(working, entryPath, r.secrets)
|
||||||
applyHistoryApplication(&working, payload.application, replayIndex)
|
applyHistoryApplication(&working, payload.application, replayIndex)
|
||||||
selected, relevant := selections.application(applicationRef)
|
selected, relevant := selections.application(applicationRef)
|
||||||
if !relevant {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
after := valueSnapshot(working, entryPath, r.secrets)
|
||||||
if !before.Present && !after.Present {
|
if !before.Present && !after.Present {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -127,13 +132,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
}
|
}
|
||||||
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
|
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
|
||||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
|
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, entryPath)
|
||||||
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
|
||||||
if selected && !configurationValuesEqual(replayed, effectiveInput) {
|
if selected && !configurationValuesEqual(replayed, effectiveInput) {
|
||||||
origin, reasons := checkpointExplanationAttribution(selection)
|
origin, reasons := checkpointExplanationAttribution(selection)
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
Before: valueSnapshot(working, entryPath, r.secrets),
|
||||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
Reasons: reasons,
|
Reasons: reasons,
|
||||||
})
|
})
|
||||||
@@ -147,7 +152,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
covering := model.resolutionPayload.snapshots[group.coveringRecordID]
|
covering := model.resolutionPayload.snapshots[group.coveringRecordID]
|
||||||
before := valueSnapshot(working, path, r.secrets)
|
before := valueSnapshot(working, entryPath, r.secrets)
|
||||||
replaceHistoryPath(&working, group.change.Path, covering.after)
|
replaceHistoryPath(&working, group.change.Path, covering.after)
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
after := valueSnapshot(working, path, r.secrets)
|
||||||
if !before.Present && !after.Present {
|
if !before.Present && !after.Present {
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ type resolutionReplayGroup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type historyResolutionPayload struct {
|
type historyResolutionPayload struct {
|
||||||
snapshots []resolutionSnapshot
|
snapshots []resolutionSnapshot
|
||||||
replayGroups []resolutionReplayGroup
|
replayGroups []resolutionReplayGroup
|
||||||
|
correspondence resolutionCorrespondence
|
||||||
}
|
}
|
||||||
|
|
||||||
// observeResolutionEvidence retains audited records in producer order and
|
// observeResolutionEvidence retains audited records in producer order and
|
||||||
// indexes the concrete shapes in every original before/after subtree. Snapshot
|
// indexes the concrete shapes in every original before/after subtree. Snapshot
|
||||||
// roots are already detached by runResolution and are shared with the replay
|
// roots are already detached by runResolution and are shared with the replay
|
||||||
// group that refers to their covering record.
|
// group that refers to their covering record.
|
||||||
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot) {
|
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot, correspondence ...resolutionCorrespondence) {
|
||||||
builder.requireOpen()
|
builder.requireOpen()
|
||||||
if len(snapshots) == 0 {
|
if len(snapshots) == 0 {
|
||||||
return
|
return
|
||||||
@@ -36,10 +37,14 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
|
|||||||
snapshots: make([]resolutionSnapshot, len(snapshots)),
|
snapshots: make([]resolutionSnapshot, len(snapshots)),
|
||||||
replayGroups: coalescedResolutionSnapshots(snapshots),
|
replayGroups: coalescedResolutionSnapshots(snapshots),
|
||||||
}
|
}
|
||||||
|
if len(correspondence) != 0 {
|
||||||
|
payload.correspondence = correspondence[0].clone()
|
||||||
|
}
|
||||||
attributions := make([]historyAttributionRef, len(snapshots))
|
attributions := make([]historyAttributionRef, len(snapshots))
|
||||||
lookup := buildDocumentLookup(builder.scratch)
|
lookup := buildDocumentLookup(builder.scratch)
|
||||||
for recordID, snapshot := range snapshots {
|
for recordID, snapshot := range snapshots {
|
||||||
snapshot.change = cloneChange(snapshot.change)
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
|
snapshot.entryPath = snapshot.entryPath.clone()
|
||||||
payload.snapshots[recordID] = snapshot
|
payload.snapshots[recordID] = snapshot
|
||||||
|
|
||||||
ref := builder.nextPayload(historyPhaseResolution)
|
ref := builder.nextPayload(historyPhaseResolution)
|
||||||
@@ -48,8 +53,12 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
|
|||||||
}
|
}
|
||||||
attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies)
|
attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies)
|
||||||
attributions[recordID] = attribution
|
attributions[recordID] = attribution
|
||||||
beforePath, beforeFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup)
|
entryPath, hasEntry := snapshot.change.Path, true
|
||||||
if beforeFound {
|
if len(correspondence) != 0 {
|
||||||
|
entryPath, hasEntry = snapshot.entryPath, snapshot.hasEntry
|
||||||
|
}
|
||||||
|
beforePath, beforeFound := historyConcretePathAt(builder.scratch, entryPath, lookup)
|
||||||
|
if hasEntry && beforeFound {
|
||||||
observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false)
|
observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,21 +26,43 @@ type historySensitivityCandidate struct {
|
|||||||
classify bool
|
classify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type historyQueryCoordinates struct {
|
||||||
|
final Path
|
||||||
|
entry Path
|
||||||
|
hasEntry bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *historyModel) queryCoordinates(path Path) historyQueryCoordinates {
|
||||||
|
coordinate := historyQueryCoordinates{final: path.clone(), entry: path.clone(), hasEntry: true}
|
||||||
|
if model != nil && model.resolutionPayload != nil {
|
||||||
|
coordinate.entry, coordinate.hasEntry = model.resolutionPayload.correspondence.entryPath(path)
|
||||||
|
}
|
||||||
|
return coordinate
|
||||||
|
}
|
||||||
|
|
||||||
func (model *historyModel) selectHistory(path Path) []historySelection {
|
func (model *historyModel) selectHistory(path Path) []historySelection {
|
||||||
if model == nil || !path.valid() || !model.hasObservedShape(path) {
|
if model == nil || !path.valid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
coordinate := model.queryCoordinates(path)
|
||||||
|
if !model.hasObservedShape(path) && (!coordinate.hasEntry || !model.hasObservedShape(coordinate.entry)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
candidateSet := make(map[int]struct{})
|
candidateSet := make(map[int]struct{})
|
||||||
for _, observationID := range model.observationsByPointer[path.String()] {
|
collect := func(query Path) {
|
||||||
candidateSet[observationID] = struct{}{}
|
for _, observationID := range model.observationsByPointer[query.String()] {
|
||||||
|
candidateSet[observationID] = struct{}{}
|
||||||
|
}
|
||||||
|
model.affectedPathIndex.visitDescendants(query.String(), func(observationID int) { candidateSet[observationID] = struct{}{} })
|
||||||
|
}
|
||||||
|
collect(path)
|
||||||
|
if coordinate.hasEntry {
|
||||||
|
collect(coordinate.entry)
|
||||||
}
|
}
|
||||||
// Producers register all affected before/after paths. Exact facts cover
|
// Producers register all affected before/after paths. Exact facts cover
|
||||||
// ancestor replacements; descendants cover operations within an enclosing
|
// ancestor replacements; descendants cover operations within an enclosing
|
||||||
// query. Logical operations are still deduplicated by payload below.
|
// query. Logical operations are still deduplicated by payload below.
|
||||||
model.affectedPathIndex.visitDescendants(path.String(), func(observationID int) {
|
|
||||||
candidateSet[observationID] = struct{}{}
|
|
||||||
})
|
|
||||||
candidates := make([]int, 0, len(candidateSet))
|
candidates := make([]int, 0, len(candidateSet))
|
||||||
for observationID := range candidateSet {
|
for observationID := range candidateSet {
|
||||||
candidates = append(candidates, observationID)
|
candidates = append(candidates, observationID)
|
||||||
@@ -54,18 +76,30 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
|
|||||||
if observation.role == historyRoleInventory {
|
if observation.role == historyRoleInventory {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
query := path
|
||||||
|
if observation.phase != historyPhaseResolution {
|
||||||
|
if !coordinate.hasEntry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query = coordinate.entry
|
||||||
|
} else if observation.beforePresent && !observation.afterPresent {
|
||||||
|
if !coordinate.hasEntry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query = coordinate.entry
|
||||||
|
}
|
||||||
reportedPath := observation.reportedPath
|
reportedPath := observation.reportedPath
|
||||||
attributionRef := observation.attributionRef
|
attributionRef := observation.attributionRef
|
||||||
if observation.role == historyRoleContribution {
|
if observation.role == historyRoleContribution {
|
||||||
if !historyObservationMatchesQuery(path, observation.path) {
|
if !historyObservationMatchesQuery(query, observation.path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
application, found := model.applicationPayload(observation.payload)
|
application, found := model.applicationPayload(observation.payload)
|
||||||
if !found {
|
if !found {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if pathContains(observation.path, path) && len(observation.path.segments) < len(path.segments) {
|
if pathContains(observation.path, query) && len(observation.path.segments) < len(query.segments) {
|
||||||
local, ok := historyApplicationLocalPath(application, path)
|
local, ok := historyApplicationLocalPath(application, query)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -73,12 +107,17 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant {
|
if operationPath, relevant := applicationOperationPath(application, query, model.documentLookup); relevant {
|
||||||
reportedPath = operationPath
|
reportedPath = operationPath
|
||||||
}
|
}
|
||||||
} else if !historyObservationMatchesQuery(path, observation.path) {
|
} else if !historyObservationMatchesQuery(query, observation.path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if observation.phase != historyPhaseResolution && query.String() != path.String() {
|
||||||
|
if translated, matched := model.resolutionPayload.correspondence.finalPath(reportedPath); matched {
|
||||||
|
reportedPath = translated
|
||||||
|
}
|
||||||
|
}
|
||||||
selectionIndex, found := selectionByPayload[observation.payload]
|
selectionIndex, found := selectionByPayload[observation.payload]
|
||||||
if !found {
|
if !found {
|
||||||
selectionIndex = len(selections)
|
selectionIndex = len(selections)
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
|
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
|
||||||
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode)
|
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode, request.sequencePolicy)
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if hasErrors(diagnostics) {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ func cloneMetadata(metadata Metadata) Metadata {
|
|||||||
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) {
|
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) {
|
||||||
finalPaths := make(map[string]*document.Node)
|
finalPaths := make(map[string]*document.Node)
|
||||||
indexDocument(resolved.final, Path{}, finalPaths)
|
indexDocument(resolved.final, Path{}, finalPaths)
|
||||||
|
fields := make(map[string]FieldInfo, len(finalPaths))
|
||||||
|
walkDocument(resolved.final, Path{}, func(path Path, _ *document.Node) {
|
||||||
|
if prior, matched := resolved.correspondence.entryPath(path); matched {
|
||||||
|
if info, found := entry.lookup(prior); found {
|
||||||
|
fields[path.String()] = cloneFieldInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
metadata.fields = fields
|
||||||
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
|
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
|
||||||
var deltaCoverage pathPrefixIndex
|
var deltaCoverage pathPrefixIndex
|
||||||
snapshotIDs := make([]int, len(resolved.deltas))
|
snapshotIDs := make([]int, len(resolved.deltas))
|
||||||
@@ -163,7 +172,7 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entr
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
|
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
|
||||||
refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage)
|
refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage, resolved.correspondence)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
|
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
|
||||||
@@ -192,7 +201,7 @@ func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) {
|
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex, correspondence resolutionCorrespondence) {
|
||||||
for pointer := range fields {
|
for pointer := range fields {
|
||||||
if _, ok := finalPaths[pointer]; !ok {
|
if _, ok := finalPaths[pointer]; !ok {
|
||||||
delete(fields, pointer)
|
delete(fields, pointer)
|
||||||
@@ -200,7 +209,9 @@ func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]
|
|||||||
}
|
}
|
||||||
walkDocument(final, Path{}, func(path Path, node *document.Node) {
|
walkDocument(final, Path{}, func(path Path, node *document.Node) {
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
entryInfo, entryFound := entry.lookup(path)
|
prior, matched := correspondence.entryPath(path)
|
||||||
|
entryInfo, entryFound := entry.lookup(prior)
|
||||||
|
entryFound = entryFound && matched
|
||||||
if info, ok := fields[pointer]; ok {
|
if info, ok := fields[pointer]; ok {
|
||||||
info.Explicit = entryFound && entryInfo.Explicit
|
info.Explicit = entryFound && entryInfo.Explicit
|
||||||
info.Null = node.Kind == document.Null
|
info.Null = node.Kind == document.Null
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
return decoded, nil
|
return decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext) (resolvedLoad[T], []Diagnostic) {
|
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext, policy sequencePolicy) (resolvedLoad[T], []Diagnostic) {
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
decoded := prepared.decoded
|
decoded := prepared.decoded
|
||||||
configuration := decoded.value.Interface().(T)
|
configuration := decoded.value.Interface().(T)
|
||||||
@@ -54,7 +54,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
var resolutionSnapshots []resolutionSnapshot
|
var resolutionSnapshots []resolutionSnapshot
|
||||||
refreshReferenceInput := false
|
refreshReferenceInput := false
|
||||||
if definition.Resolve != nil {
|
if definition.Resolve != nil {
|
||||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution)
|
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution, policy)
|
||||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||||
if hasErrors(resolved.diagnostics) {
|
if hasErrors(resolved.diagnostics) {
|
||||||
return resolvedLoad[T]{}, diagnostics
|
return resolvedLoad[T]{}, diagnostics
|
||||||
@@ -62,7 +62,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
final = resolved.final
|
final = resolved.final
|
||||||
resolutionSnapshots = resolved.snapshots
|
resolutionSnapshots = resolved.snapshots
|
||||||
if builder != nil && len(resolutionSnapshots) != 0 {
|
if builder != nil && len(resolutionSnapshots) != 0 {
|
||||||
builder.observeResolutionEvidence(resolutionSnapshots)
|
builder.observeResolutionEvidence(resolutionSnapshots, resolved.correspondence)
|
||||||
}
|
}
|
||||||
applyResolutionMetadata(&metadata, resolved, prepared.resolution)
|
applyResolutionMetadata(&metadata, resolved, prepared.resolution)
|
||||||
finalizeEffectiveMetadataPaths(&metadata, final)
|
finalizeEffectiveMetadataPaths(&metadata, final)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func (m Metadata) effectiveField(path Path) (FieldInfo, bool) {
|
|||||||
// Unknown and invalid paths return nil.
|
// Unknown and invalid paths return nil.
|
||||||
func (m Metadata) History(path Path) []Change {
|
func (m Metadata) History(path Path) []Change {
|
||||||
model := m.historyEvidence()
|
model := m.historyEvidence()
|
||||||
if model == nil || !model.hasObservedShape(path) {
|
if model == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
selections := model.selectHistory(path)
|
selections := model.selectHistory(path)
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
|||||||
value.Items[0] = "normalized"
|
value.Items[0] = "normalized"
|
||||||
resolution.RecordNormalization(Keys("items"), "normalized item")
|
resolution.RecordNormalization(Keys("items"), "normalized item")
|
||||||
return nil
|
return nil
|
||||||
}, schema, decoded, document, input)
|
}, schema, decoded, document, input, sequencePolicy{})
|
||||||
if len(resolved.diagnostics) != 0 {
|
if len(resolved.diagnostics) != 0 {
|
||||||
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb
|
|||||||
}
|
}
|
||||||
var lookup *documentLookup
|
var lookup *documentLookup
|
||||||
fallbackLocation := func(path Path) (document.Location, bool) {
|
fallbackLocation := func(path Path) (document.Location, bool) {
|
||||||
|
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
|
||||||
|
var matched bool
|
||||||
|
path, matched = model.resolutionPayload.correspondence.entryPath(path)
|
||||||
|
if !matched {
|
||||||
|
return document.Location{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
if lookup == nil {
|
if lookup == nil {
|
||||||
lookup = buildDocumentLookup(fallbackRoot)
|
lookup = buildDocumentLookup(fallbackRoot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
|||||||
value.Count = 6
|
value.Count = 6
|
||||||
resolution.RecordNormalization(Keys("count"), "normalize count")
|
resolution.RecordNormalization(Keys("count"), "normalize count")
|
||||||
return nil
|
return nil
|
||||||
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned))
|
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned), sequencePolicy{})
|
||||||
if len(resolveDiagnostics) != 0 {
|
if len(resolveDiagnostics) != 0 {
|
||||||
t.Fatalf("resolveLoadInput() diagnostics = %#v", resolveDiagnostics)
|
t.Fatalf("resolveLoadInput() diagnostics = %#v", resolveDiagnostics)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
|||||||
resolution.RecordNormalization(Keys("api_key"), "select effective secret reference")
|
resolution.RecordNormalization(Keys("api_key"), "select effective secret reference")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution)
|
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution, sequencePolicy{})
|
||||||
if len(resolved.diagnostics) != 0 || configuration.Count != 5 || configuration.Derived != 42 {
|
if len(resolved.diagnostics) != 0 || configuration.Count != 5 || configuration.Derived != 42 {
|
||||||
t.Fatalf("resolution = %#v, configuration = %#v", resolved, configuration)
|
t.Fatalf("resolution = %#v, configuration = %#v", resolved, configuration)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,16 +59,19 @@ func (record *resolutionRecord) addDependency(path Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type resolutionSnapshot struct {
|
type resolutionSnapshot struct {
|
||||||
change Change
|
change Change
|
||||||
before *document.Node
|
before *document.Node
|
||||||
after *document.Node
|
after *document.Node
|
||||||
|
entryPath Path
|
||||||
|
hasEntry bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type resolutionResult struct {
|
type resolutionResult struct {
|
||||||
final *document.Node
|
final *document.Node
|
||||||
deltas []documentDelta
|
deltas []documentDelta
|
||||||
snapshots []resolutionSnapshot
|
snapshots []resolutionSnapshot
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
|
correspondence resolutionCorrespondence
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExplicit reports whether surviving direct or inherited input defined path.
|
// IsExplicit reports whether surviving direct or inherited input defined path.
|
||||||
@@ -289,7 +292,7 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
|
|||||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
|
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput, policy sequencePolicy) resolutionResult {
|
||||||
state := &resolutionState{
|
state := &resolutionState{
|
||||||
active: true, schema: schema, document: input,
|
active: true, schema: schema, document: input,
|
||||||
input: entry,
|
input: entry,
|
||||||
@@ -313,7 +316,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
}
|
}
|
||||||
deltas := compareConfigurationValues(decoded.final, after)
|
correspondence := buildResolutionCorrespondence(decoded.final, after, policy)
|
||||||
|
deltas := compareResolutionValues(decoded.final, after, correspondence)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
coverage := newResolutionCoverageIndexFromRecords(ordered)
|
coverage := newResolutionCoverageIndexFromRecords(ordered)
|
||||||
beforeLookup := buildDocumentLookup(decoded.final)
|
beforeLookup := buildDocumentLookup(decoded.final)
|
||||||
@@ -324,7 +328,9 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
_, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
|
beforePath, hasEntry := correspondence.entryPath(record.path)
|
||||||
|
_, beforePresent := documentAtPathWithLookup(decoded.final, beforePath, beforeLookup)
|
||||||
|
beforePresent = beforePresent && hasEntry
|
||||||
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
|
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
|
||||||
if !beforePresent && !afterPresent {
|
if !beforePresent && !afterPresent {
|
||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
@@ -340,7 +346,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
|
|
||||||
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
|
beforePath, hasEntry := correspondence.entryPath(record.path)
|
||||||
|
var beforeNode *document.Node
|
||||||
|
if hasEntry {
|
||||||
|
beforeNode, _ = documentAtPathWithLookup(decoded.final, beforePath, beforeLookup)
|
||||||
|
}
|
||||||
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
|
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
|
||||||
snapshots = append(snapshots, resolutionSnapshot{
|
snapshots = append(snapshots, resolutionSnapshot{
|
||||||
change: Change{
|
change: Change{
|
||||||
@@ -349,11 +359,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
Reasons: append([]string(nil), record.reasons...),
|
Reasons: append([]string(nil), record.reasons...),
|
||||||
Dependencies: clonePaths(record.dependencies),
|
Dependencies: clonePaths(record.dependencies),
|
||||||
},
|
},
|
||||||
before: document.Clone(beforeNode), after: document.Clone(afterNode),
|
before: document.Clone(beforeNode), after: document.Clone(afterNode), entryPath: beforePath, hasEntry: hasEntry,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return resolutionResult{
|
return resolutionResult{
|
||||||
final: after, deltas: deltas, snapshots: snapshots,
|
final: after, deltas: deltas, snapshots: snapshots, correspondence: correspondence,
|
||||||
diagnostics: canonicalDiagnostics(state.diagnostics),
|
diagnostics: canonicalDiagnostics(state.diagnostics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
internal/engine/resolution_compare.go
Normal file
60
internal/engine/resolution_compare.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/configkit/internal/document"
|
||||||
|
|
||||||
|
// compareResolutionValues reports field-level changes at stable list positions.
|
||||||
|
func compareResolutionValues(before, after *document.Node, correspondence ...resolutionCorrespondence) []documentDelta {
|
||||||
|
var deltas []documentDelta
|
||||||
|
var compare func(*document.Node, *document.Node, Path)
|
||||||
|
compare = func(left, right *document.Node, path Path) {
|
||||||
|
if left == nil || right == nil || left.Kind != right.Kind {
|
||||||
|
compareConfigurationValue(left, right, path, &deltas)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch left.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
leftEntries := make(map[string]*document.Node, len(left.Mapping))
|
||||||
|
for _, entry := range left.Mapping {
|
||||||
|
leftEntries[entry.Key] = entry.Value
|
||||||
|
}
|
||||||
|
for _, entry := range right.Mapping {
|
||||||
|
compare(leftEntries[entry.Key], entry.Value, path.Key(entry.Key))
|
||||||
|
delete(leftEntries, entry.Key)
|
||||||
|
}
|
||||||
|
for key, value := range leftEntries {
|
||||||
|
compare(value, nil, path.Key(key))
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
if len(correspondence) != 0 {
|
||||||
|
if collection, registered := correspondence[0].collections[path.String()]; registered {
|
||||||
|
if !collection.eligible {
|
||||||
|
if !configurationValuesEqual(left, right) {
|
||||||
|
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if collection.structural {
|
||||||
|
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
}
|
||||||
|
for finalIndex, entryIndex := range collection.finalToEntry {
|
||||||
|
if entryIndex >= 0 {
|
||||||
|
compare(left.Sequence[entryIndex], right.Sequence[finalIndex], path.Index(finalIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(left.Sequence) != len(right.Sequence) {
|
||||||
|
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index := range left.Sequence {
|
||||||
|
compare(left.Sequence[index], right.Sequence[index], path.Index(index))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
compareConfigurationValue(left, right, path, &deltas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compare(before, after, Path{})
|
||||||
|
return sortDocumentDeltas(deltas)
|
||||||
|
}
|
||||||
711
internal/engine/resolution_compare_test.go
Normal file
711
internal/engine/resolution_compare_test.go
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolutionAuditsOrdinaryListFields(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Nested []*int `yaml:"nested"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
other, resize bool
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{name: "covered field"},
|
||||||
|
{name: "uncovered field", other: true, wantPath: "/items/0/enabled"},
|
||||||
|
{name: "length change", resize: true, wantPath: "/items"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := testLoad(Bytes("items", []byte("items: [{count: 3, enabled: true, nested: [null]}]\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0].Count = 0
|
||||||
|
r.RecordNormalization(Keys("items").Index(0).Key("count"), "clear count")
|
||||||
|
if test.other {
|
||||||
|
value.Items[0].Enabled = false
|
||||||
|
}
|
||||||
|
if test.resize {
|
||||||
|
value.Items = append(value.Items, item{})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if test.wantPath == "" {
|
||||||
|
if err != nil || result.Config.Items[0].Count != 0 {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeResolveUnrecordedChange && diagnostic.Path.String() == test.wantPath {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionComparisonKeepsDiffAtomic(t *testing.T) {
|
||||||
|
before := parseCompositionDocument(t, "before", "items: [{values: [true, null]}]")
|
||||||
|
after := parseCompositionDocument(t, "after", "items: [{values: [false, 0]}]")
|
||||||
|
deltas := compareResolutionValues(before, after)
|
||||||
|
if len(deltas) != 2 || deltas[0].path.String() != "/items/0/values/0" || deltas[1].path.String() != "/items/0/values/1" {
|
||||||
|
t.Fatalf("resolution deltas = %#v", deltas)
|
||||||
|
}
|
||||||
|
atomic := compareConfigurationValues(before, after)
|
||||||
|
if len(atomic) != 1 || atomic[0].path.String() != "/items" {
|
||||||
|
t.Fatalf("atomic deltas = %#v", atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionAuditsRegisteredCollectionChanges(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
move, container, rename bool
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{name: "field edit"},
|
||||||
|
{name: "uncovered move", move: true, wantPath: "/items"},
|
||||||
|
{name: "covered move", move: true, container: true},
|
||||||
|
{name: "identity replacement", rename: true, wantPath: "/items"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
definition := Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0].Value = "changed"
|
||||||
|
if test.move {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
}
|
||||||
|
if test.rename {
|
||||||
|
value.Items[0].ID = "new"
|
||||||
|
}
|
||||||
|
index := 0
|
||||||
|
if test.move {
|
||||||
|
index = 1
|
||||||
|
}
|
||||||
|
r.RecordNormalization(Keys("items").Index(index).Key("value"), "change value")
|
||||||
|
if test.container {
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
definition = DefinitionWithSequenceIdentityRegistrations(definition, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("items", []byte("items: [{id: a, value: old}, {id: b, value: other}]\n")), Options[config]{Definition: definition})
|
||||||
|
if test.wantPath == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.move {
|
||||||
|
correspondence := result.history.resolutionPayload.correspondence
|
||||||
|
entry, ok := correspondence.entryPath(Keys("items").Index(1).Key("value"))
|
||||||
|
if !ok || entry.String() != "/items/0/value" {
|
||||||
|
t.Fatalf("retained entry = %s, %t", entry.String(), ok)
|
||||||
|
}
|
||||||
|
for _, snapshot := range result.history.resolutionPayload.snapshots {
|
||||||
|
if snapshot.change.Path.String() == "/items/1/value" && (snapshot.before == nil || snapshot.before.Text != "old" || !snapshot.hasEntry || snapshot.entryPath.String() != "/items/0/value") {
|
||||||
|
t.Fatalf("paired before = %#v", snapshot.before)
|
||||||
|
}
|
||||||
|
if snapshot.before != nil {
|
||||||
|
found := false
|
||||||
|
for _, root := range result.history.documentRoots() {
|
||||||
|
if root == snapshot.before {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("paired snapshot omitted from retained roots")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeResolveUnrecordedChange && diagnostic.Path.String() == test.wantPath {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionFallbackAndHelperTarget(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
registration := []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}
|
||||||
|
makeDefinition := func(resolve func(*config, *Resolution) error) Definition[config] {
|
||||||
|
return DefinitionWithSequenceIdentityRegistrations(Definition[config]{Defaults: func() config { return config{Items: []item{{ID: "a"}, {ID: "a"}}} }, Resolve: resolve}, registration)
|
||||||
|
}
|
||||||
|
_, err := testLoad(NoSource(), Options[config]{Definition: makeDefinition(func(value *config, r *Resolution) error {
|
||||||
|
value.Items[1].ID = "b"
|
||||||
|
r.RecordNormalization(Keys("items"), "repair identities")
|
||||||
|
return nil
|
||||||
|
})})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repair rejected: %v", err)
|
||||||
|
}
|
||||||
|
_, err = testLoad(NoSource(), Options[config]{Definition: makeDefinition(func(*config, *Resolution) error { return nil })})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid final identities accepted")
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
Normalize(r, Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize")
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}, registration)
|
||||||
|
_, err = testLoad(Bytes("items", []byte("items: [{id: a, value: first}, {id: b, value: second}]\n")), Options[config]{Definition: definition})
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("helper error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeResolveInvalidTarget && diagnostic.Path.String() == "/items/0/value" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionMetadataFollowsEntryIdentity(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
entryFixed := false
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
entryFixed = r.IsExplicit(Keys("items").Index(0).Key("enabled")) && !r.IsExplicit(Keys("items").Index(1).Key("enabled"))
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Items = append(value.Items, item{ID: "new"})
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder and add")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, enabled: false}, {id: b, count: 0}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil || !entryFixed {
|
||||||
|
t.Fatalf("load = %#v, fixed entry = %t, error = %v", result, entryFixed, err)
|
||||||
|
}
|
||||||
|
for _, check := range []struct {
|
||||||
|
index int
|
||||||
|
key string
|
||||||
|
explicit bool
|
||||||
|
}{{0, "enabled", false}, {0, "count", true}, {1, "enabled", true}, {1, "count", false}, {2, "id", false}} {
|
||||||
|
info, ok := result.Metadata().Lookup(Keys("items").Index(check.index).Key(check.key))
|
||||||
|
if !ok || info.Explicit != check.explicit || info.Origin.Layer != LayerResolution {
|
||||||
|
t.Errorf("item %d %s = %#v, %t", check.index, check.key, info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionValidationLocationFollowsMovedRecord(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(_ *config, v *Validation) { v.Error(Keys("items").Index(0).Key("value"), "invalid") },
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
_, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition})
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Path.String() == "/items/0/value" && diagnostic.Primary != nil && diagnostic.Primary.Line == 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionHistoryFollowsMatchedSource(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Items = append(value.Items, item{ID: "new", Value: "added"})
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder and add")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, check := range []struct{ index, line int }{{0, 5}, {1, 3}} {
|
||||||
|
path := Keys("items").Index(check.index).Key("value")
|
||||||
|
if location, ok := result.history.latestHistoricalLocation(path); !ok || location.Line != check.line {
|
||||||
|
t.Errorf("historical location for %s = %#v, %t", path.String(), location, ok)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(path)
|
||||||
|
found := false
|
||||||
|
for _, change := range history {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
if location.Line == check.line {
|
||||||
|
found = true
|
||||||
|
} else if location.Line == 3 || location.Line == 5 {
|
||||||
|
t.Errorf("wrong record in %s: %#v", path.String(), history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing source line %d in %#v", check.line, history)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing explanation for %s", path.String())
|
||||||
|
}
|
||||||
|
want := "second\n"
|
||||||
|
if check.index == 1 {
|
||||||
|
want = "first\n"
|
||||||
|
}
|
||||||
|
foundValue := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerMain && change.After.YAML == want {
|
||||||
|
foundValue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundValue {
|
||||||
|
t.Errorf("missing matched input %q in %#v", want, explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, change := range result.Metadata().History(Keys("items").Index(2).Key("value")) {
|
||||||
|
if change.Origin.Layer == LayerMain {
|
||||||
|
t.Fatalf("added record inherited input: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedRecordExplanationIncludesInheritedValue(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: parent, value: inherited}, {id: child, extends: {id: parent}}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.After.YAML == "inherited\n" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("inherited history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedRecordExplanationIncludesCLIValue(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(NoSource(), Options[config]{Definition: definition, Overrides: []Override{{
|
||||||
|
Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "a", "value": "first"}, map[string]any{"id": "b", "value": "second"}},
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerCLI && change.After.YAML == "first\n" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("CLI history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionExplainsRemovalAndReusedPositions(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items = value.Items[1:]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove first")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := Keys("items").Index(0).Key("value")
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("survivor explanation missing")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
if change.Before.YAML != "invoice\n" || change.After.YAML != "invoice\n" {
|
||||||
|
t.Fatalf("survivor operation = %#v", change)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerMain && change.After.YAML == "report\n" {
|
||||||
|
t.Fatalf("removed occupant leaked into survivor: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing collection operation")
|
||||||
|
}
|
||||||
|
container, ok := result.Inspection.Explain(Keys("items"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("collection explanation missing")
|
||||||
|
}
|
||||||
|
found = false
|
||||||
|
for _, change := range container.History {
|
||||||
|
if change.Origin.Layer == LayerResolution && strings.Contains(change.Before.YAML, "reports") && !strings.Contains(change.After.YAML, "reports") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing removed collection evidence: %#v", container.History)
|
||||||
|
}
|
||||||
|
mainEvidence := false
|
||||||
|
for _, change := range result.Metadata().History(Keys("items")) {
|
||||||
|
if change.Origin.Layer == LayerMain && len(change.Origin.Locations) != 0 {
|
||||||
|
mainEvidence = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mainEvidence {
|
||||||
|
t.Fatal("collection history lost source attribution")
|
||||||
|
}
|
||||||
|
if _, ok := result.Inspection.Explain(Keys("items").Index(1)); !ok {
|
||||||
|
t.Fatal("absent historical position missing")
|
||||||
|
}
|
||||||
|
if _, ok := result.Inspection.Explain(Keys("items").Index(8)); ok {
|
||||||
|
t.Fatal("unknown position reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionExplainsAddedAndChangedIdentities(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
for _, replace := range []bool{false, true} {
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
if replace {
|
||||||
|
value.Items[0] = item{ID: "new", Value: "added"}
|
||||||
|
} else {
|
||||||
|
value.Items = append([]item{{ID: "new", Value: "added"}}, value.Items...)
|
||||||
|
}
|
||||||
|
r.RecordNormalization(Keys("items"), "update collection")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: old, value: original}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("added explanation missing")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerMain || (change.Origin.Layer == LayerResolution && change.Before.Present) {
|
||||||
|
t.Fatalf("added record inherited previous value: %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replace {
|
||||||
|
moved, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("shifted survivor missing")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range moved.History {
|
||||||
|
if change.Origin.Layer == LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "original\n" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("shifted survivor = %#v", moved.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionExplainsRemovedFieldOnMovedRecord(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
delete(value.Items[0], "extra")
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove and reorder")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b, extra: other}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("extra"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("removed field explanation missing")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerResolution && change.Before.YAML == "own\n" && !change.After.Present {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("removed field history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionTransfersSensitivityFromEntry(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Derived = value.Items[1].Value
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items").Index(0).Key("value"), Keys("items").Index(1).Key("value"), Keys("derived")} {
|
||||||
|
if !pathIsSecret(result.Inspection.secrets, path) {
|
||||||
|
t.Errorf("sensitivity lost at %s", path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionKeepsRemovedSensitivity(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items = value.Items[1:]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove record")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !pathIsSecret(result.Inspection.secrets, Keys("items").Index(0).Key("value")) {
|
||||||
|
t.Fatal("removed historical sensitivity lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedAndRemovedSensitiveValuesStayHidden(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
for _, remove := range []bool{false, true} {
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/1/value" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Derived = value.Items[1].Value
|
||||||
|
r.RecordNormalization(Keys("items").Index(1).Key("value"), "retain value", Keys("derived"))
|
||||||
|
if remove {
|
||||||
|
value.Items = value.Items[:1]
|
||||||
|
}
|
||||||
|
r.RecordNormalization(Keys("items"), "update records")
|
||||||
|
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: PRIVATE_CANARY}, {id: b, value: other}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items"), Keys("items").Index(0), Keys("items").Index(1), Keys("derived")} {
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("effective leaked at %s", path.String())
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "PRIVATE_CANARY") || strings.Contains(change.After.YAML, "PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("history leaked at %s: %#v", path.String(), change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := result.Inspection.WriteEffective(&output); err != nil || strings.Contains(output.String(), "PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("effective output leaked: %q, %v", output.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedDynamicTaggedValueStaysHidden(t *testing.T) {
|
||||||
|
type sensitive struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Token string `yaml:"token" configkit:"secret"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []any `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Defaults: func() config {
|
||||||
|
return config{Items: []any{sensitive{ID: "a", Token: "DYNAMIC_PRIVATE_CANARY"}, sensitive{ID: "b", Token: "other"}}}
|
||||||
|
},
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(NoSource(), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items"), Keys("items").Index(1).Key("token")} {
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing explanation %s", path.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "DYNAMIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("effective value leaked")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "DYNAMIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "DYNAMIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("history leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedDependencyProtectsEarlierMatchedValue(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/secret" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Items[0].Value = value.Items[1].Secret
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
r.RecordNormalization(Keys("items").Index(0).Key("value"), "derive", Keys("items").Index(1).Key("secret"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, secret: HIDDEN_A, value: public}, {id: b, value: EARLIER_PRIVATE}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "HIDDEN_A") {
|
||||||
|
t.Fatalf("sensitive lineage leaked: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/engine/resolution_correspondence.go
Normal file
165
internal/engine/resolution_correspondence.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/configkit/internal/document"
|
||||||
|
|
||||||
|
// collectionCorrespondence describes matching at one concrete registered path.
|
||||||
|
// A negative index denotes an identity present only at the other endpoint.
|
||||||
|
type collectionCorrespondence struct {
|
||||||
|
path Path
|
||||||
|
eligible bool
|
||||||
|
structural bool
|
||||||
|
entryToFinal []int
|
||||||
|
finalToEntry []int
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolutionCorrespondence struct {
|
||||||
|
collections map[string]collectionCorrespondence
|
||||||
|
prefix pathPrefixIndex
|
||||||
|
indexedCollections []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c resolutionCorrespondence) clone() resolutionCorrespondence {
|
||||||
|
var result resolutionCorrespondence
|
||||||
|
for _, collection := range c.collections {
|
||||||
|
collection.path = collection.path.clone()
|
||||||
|
collection.entryToFinal = append([]int(nil), collection.entryToFinal...)
|
||||||
|
collection.finalToEntry = append([]int(nil), collection.finalToEntry...)
|
||||||
|
result.indexCollection(collection)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *resolutionCorrespondence) indexCollection(collection collectionCorrespondence) {
|
||||||
|
if c.collections == nil {
|
||||||
|
c.collections = make(map[string]collectionCorrespondence)
|
||||||
|
}
|
||||||
|
pointer := collection.path.String()
|
||||||
|
c.collections[pointer] = collection
|
||||||
|
if collection.eligible {
|
||||||
|
c.prefix.insert(pointer, len(c.indexedCollections))
|
||||||
|
c.indexedCollections = append(c.indexedCollections, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResolutionCorrespondence(entry, final *document.Node, policy sequencePolicy) resolutionCorrespondence {
|
||||||
|
var result resolutionCorrespondence
|
||||||
|
if policy.selectorTrie == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var indexes mappingIndex
|
||||||
|
var visit func(*document.Node, *document.Node, Path)
|
||||||
|
visit = func(before, after *document.Node, path Path) {
|
||||||
|
if fields, registered := policy.identityFieldsAt(path); registered &&
|
||||||
|
((before != nil && before.Kind == document.Sequence) || (after != nil && after.Kind == document.Sequence)) {
|
||||||
|
result.indexCollection(matchResolutionCollection(before, after, path, fields))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if before != nil && before.Kind == document.Mapping {
|
||||||
|
for _, item := range before.Mapping {
|
||||||
|
var next *document.Node
|
||||||
|
if after != nil && after.Kind == document.Mapping {
|
||||||
|
if position := indexes.find(after, item.Key); position >= 0 {
|
||||||
|
next = after.Mapping[position].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(item.Value, next, path.Key(item.Key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after != nil && after.Kind == document.Mapping {
|
||||||
|
for _, item := range after.Mapping {
|
||||||
|
if before == nil || before.Kind != document.Mapping || indexes.find(before, item.Key) < 0 {
|
||||||
|
visit(nil, item.Value, path.Key(item.Key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(entry, final, Path{})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchResolutionCollection(before, after *document.Node, path Path, fields []string) collectionCorrespondence {
|
||||||
|
result := collectionCorrespondence{path: path.clone()}
|
||||||
|
prior, priorIssues := validateNormalizedSequenceIdentityFrom(before, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
|
||||||
|
later, laterIssues := validateNormalizedSequenceIdentityFrom(after, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
|
||||||
|
if len(priorIssues) != 0 || len(laterIssues) != 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.eligible = true
|
||||||
|
result.entryToFinal = make([]int, len(before.Sequence))
|
||||||
|
result.finalToEntry = make([]int, len(after.Sequence))
|
||||||
|
for i := range result.entryToFinal {
|
||||||
|
result.entryToFinal[i] = -1
|
||||||
|
}
|
||||||
|
for i := range result.finalToEntry {
|
||||||
|
result.finalToEntry[i] = -1
|
||||||
|
}
|
||||||
|
result.structural = len(prior) != len(later)
|
||||||
|
for tuple, previous := range prior {
|
||||||
|
if current, found := later[tuple]; found {
|
||||||
|
result.entryToFinal[previous.index] = current.index
|
||||||
|
result.finalToEntry[current.index] = previous.index
|
||||||
|
if previous.index != current.index {
|
||||||
|
result.structural = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.structural = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryPath translates a surviving final record prefix; additions have no entry.
|
||||||
|
// Paths outside eligible collections and wholly absent final positions stay positional.
|
||||||
|
func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) {
|
||||||
|
return c.translate(path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalPath translates historical record descendants without matching removals.
|
||||||
|
func (c resolutionCorrespondence) finalPath(path Path) (Path, bool) {
|
||||||
|
return c.translate(path, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c resolutionCorrespondence) translate(path Path, toFinal bool) (Path, bool) {
|
||||||
|
if !path.valid() {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
translated := path.clone()
|
||||||
|
if len(c.indexedCollections) == 0 {
|
||||||
|
return translated, true
|
||||||
|
}
|
||||||
|
matched, handled := true, false
|
||||||
|
c.prefix.visitAncestors(path.String(), func(id int) {
|
||||||
|
if handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collection := c.collections[c.indexedCollections[id]]
|
||||||
|
depth := len(collection.path.segments)
|
||||||
|
if depth >= len(path.segments) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, segment := range collection.path.segments {
|
||||||
|
if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index, ok := schemaIndex(path.segments[depth])
|
||||||
|
indices := collection.finalToEntry
|
||||||
|
if toFinal {
|
||||||
|
indices = collection.entryToFinal
|
||||||
|
}
|
||||||
|
if !ok || index >= len(indices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
current := indices[index]
|
||||||
|
if current < 0 {
|
||||||
|
matched = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: current}
|
||||||
|
})
|
||||||
|
if !matched {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
return translated, true
|
||||||
|
}
|
||||||
73
internal/engine/resolution_correspondence_test.go
Normal file
73
internal/engine/resolution_correspondence_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolutionCollectionCorrespondence(t *testing.T) {
|
||||||
|
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||||
|
}})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, before, after string
|
||||||
|
eligible, structural bool
|
||||||
|
finalIndex, entryIndex int
|
||||||
|
matched bool
|
||||||
|
}{
|
||||||
|
{"swap", "items: [{id: a}, {id: b}]", "items: [{id: b}, {id: a}]", true, true, 0, 1, true},
|
||||||
|
{"insert", "items: [{id: a}]", "items: [{id: b}, {id: a}]", true, true, 0, 0, false},
|
||||||
|
{"remove", "items: [{id: a}, {id: b}]", "items: [{id: b}]", true, true, 0, 1, true},
|
||||||
|
{"same", "items: [{id: a}]", "items: [{id: a}]", true, false, 0, 0, true},
|
||||||
|
{"duplicate", "items: [{id: a}, {id: a}]", "items: [{id: a}]", false, false, 0, 0, true},
|
||||||
|
{"missing", "items: [{name: a}]", "items: [{id: a}]", false, false, 0, 0, true},
|
||||||
|
{"identity replacement", "items: [{id: a}]", "items: [{id: b}]", true, true, 0, 0, false},
|
||||||
|
{"invalid final", "items: [{id: a}]", "items: [bad]", false, false, 0, 0, true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
before := parseCompositionDocument(t, "before", test.before)
|
||||||
|
after := parseCompositionDocument(t, "after", test.after)
|
||||||
|
matching := buildResolutionCorrespondence(before, after, policy)
|
||||||
|
collection := matching.collections["/items"]
|
||||||
|
if collection.eligible != test.eligible || collection.structural != test.structural {
|
||||||
|
t.Fatalf("collection = %#v", collection)
|
||||||
|
}
|
||||||
|
entry, ok := matching.entryPath(Keys("items").Index(test.finalIndex).Key("value"))
|
||||||
|
if ok != test.matched || (ok && entry.String() != Keys("items").Index(test.entryIndex).Key("value").String()) {
|
||||||
|
t.Fatalf("entry = %s, matched = %t", entry.String(), ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionCorrespondenceDynamicCompoundIdentities(t *testing.T) {
|
||||||
|
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("groups"), anyMappingSequenceSelector(), literalSequenceSelector("items")},
|
||||||
|
identityFields: []string{"id", "kind"},
|
||||||
|
}})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
before := parseCompositionDocument(t, "before", "groups: {alpha: {items: [{id: 1, kind: x}, {id: '1', kind: x}]}, empty: {items: []}}")
|
||||||
|
after := parseCompositionDocument(t, "after", "groups: {alpha: {items: [{id: '1', kind: x}, {id: 1.0, kind: x}]}, empty: {items: []}}")
|
||||||
|
matching := buildResolutionCorrespondence(before, after, policy)
|
||||||
|
retained := matching.clone()
|
||||||
|
changed := matching.collections["/groups/alpha/items"]
|
||||||
|
changed.finalToEntry[0] = 0
|
||||||
|
matching.collections["/groups/alpha/items"] = changed
|
||||||
|
if entry, ok := retained.entryPath(Keys("groups", "alpha", "items").Index(0)); !ok || entry.String() != "/groups/alpha/items/1" {
|
||||||
|
t.Fatalf("retained correspondence shares mutable indexes: %s, %t", entry.String(), ok)
|
||||||
|
}
|
||||||
|
matching = retained
|
||||||
|
if empty := matching.collections["/groups/empty/items"]; !empty.eligible || empty.structural {
|
||||||
|
t.Fatalf("empty = %#v", empty)
|
||||||
|
}
|
||||||
|
path, ok := matching.entryPath(Keys("groups", "alpha", "items").Index(0).Key("missing"))
|
||||||
|
if !ok || path.String() != "/groups/alpha/items/1/missing" {
|
||||||
|
t.Fatalf("entry = %s, %t", path.String(), ok)
|
||||||
|
}
|
||||||
|
wrongShape, ok := matching.entryPath(Keys("groups").Index(0).Key("items").Index(0))
|
||||||
|
if !ok || wrongShape.String() != "/groups/0/items/0" {
|
||||||
|
t.Fatalf("mapping shape = %s, %t", wrongShape.String(), ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
187
internal/engine/resolution_remediation_test.go
Normal file
187
internal/engine/resolution_remediation_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMovedRemovedFieldHasConsistentHistory(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
delete(value.Items[0], "extra")
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove field and reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := Keys("items").Index(1).Key("extra")
|
||||||
|
if _, found := result.Metadata().Lookup(path); found {
|
||||||
|
t.Fatal("removed field is effective")
|
||||||
|
}
|
||||||
|
explanation, found := result.Inspection.Explain(path)
|
||||||
|
if !found || len(explanation.History) != 2 {
|
||||||
|
t.Fatalf("explanation = %#v, %t", explanation, found)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(path)
|
||||||
|
if len(history) != 2 || history[0].Origin.Layer != LayerMain || history[1].Origin.Operation != ChangeNormalization {
|
||||||
|
t.Fatalf("metadata history = %#v", history)
|
||||||
|
}
|
||||||
|
last := explanation.History[1]
|
||||||
|
if last.Before.YAML != "own\n" || last.After.Present {
|
||||||
|
t.Fatalf("removal = %#v", last)
|
||||||
|
}
|
||||||
|
for _, unknown := range []Path{Keys("items", "1", "extra"), Keys("items").Index(1).Key("unknown")} {
|
||||||
|
if history := result.Metadata().History(unknown); len(history) != 0 {
|
||||||
|
t.Fatalf("unexpected history for %s: %#v", unknown.String(), history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedInheritedHistoryUsesFinalRecordPrefix(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
}
|
||||||
|
field := Keys("items").Index(0).Key("nested").Key("more").Key("value")
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
r.RecordNormalization(field, "retain inherited field")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items:\n - id: parent\n nested: {more: {value: old}}\n - id: child\n extends: {id: parent}\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
physical := result.Metadata().History(Keys("items"))
|
||||||
|
sourceID := result.Inspection.Sources()[0].ID
|
||||||
|
for _, query := range []Path{field, Keys("items").Index(0)} {
|
||||||
|
inherited, recorded := false, false
|
||||||
|
for _, change := range result.Metadata().History(query) {
|
||||||
|
if change.Origin.Operation == ChangeInheritance {
|
||||||
|
inherited = true
|
||||||
|
if !pathContains(Keys("items").Index(0), change.Path) {
|
||||||
|
t.Errorf("inheritance path for %s = %s", query.String(), change.Path.String())
|
||||||
|
}
|
||||||
|
if query.String() == field.String() && change.Path.String() != "/items/0" {
|
||||||
|
t.Errorf("container operation replaced by leaf path: %s", change.Path.String())
|
||||||
|
}
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
if location.SourceID != sourceID || (query.String() == field.String() && location.Line != 3) {
|
||||||
|
t.Errorf("source coordinates changed: %#v", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerResolution && len(change.Reasons) == 1 && change.Reasons[0] == "retain inherited field" {
|
||||||
|
recorded = true
|
||||||
|
if change.Path.String() != field.String() {
|
||||||
|
t.Errorf("final resolution path translated twice: %s", change.Path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !inherited || !recorded {
|
||||||
|
t.Errorf("missing inherited or recorded evidence for %s", query.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, change := range physical {
|
||||||
|
if change.Origin.Operation == ChangeInheritance && !pathContains(Keys("items").Index(1), change.Path) {
|
||||||
|
t.Errorf("collection history lost physical coordinates: %s", change.Path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCyclicMatchingProtectsRemovedAndDerivedValues(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
removed := Keys("items").Index(1).Key("extra")
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/extra" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Derived = value.Items[0]["extra"].(string)
|
||||||
|
delete(value.Items[0], "extra")
|
||||||
|
last := value.Items[len(value.Items)-1]
|
||||||
|
copy(value.Items[1:], value.Items[:len(value.Items)-1])
|
||||||
|
value.Items[0] = last
|
||||||
|
r.RecordNormalization(Keys("items"), "remove field and rotate")
|
||||||
|
r.RecordDerivedDefault(Keys("derived"), "derive from removed value", removed)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: CYCLIC_PRIVATE_CANARY}, {id: b}, {id: c}, {id: d}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items"), removed, Keys("derived")} {
|
||||||
|
explanation, found := result.Inspection.Explain(path)
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing explanation for %s", path.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "CYCLIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("effective value leaked at %s", path.String())
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "CYCLIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "CYCLIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("history leaked at %s", path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := result.Inspection.WriteEffective(&output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(output.String(), "CYCLIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("effective rendering leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrespondenceIndexPreservesPathShapes(t *testing.T) {
|
||||||
|
destination := Keys("groups", "0", "a/b~")
|
||||||
|
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{
|
||||||
|
literalSequenceSelector("groups"), literalSequenceSelector("0"), literalSequenceSelector("a/b~"),
|
||||||
|
},
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
}})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
before := parseCompositionDocument(t, "before", "groups: {'0': {'a/b~': [{id: a}, {id: b}]}}")
|
||||||
|
after := parseCompositionDocument(t, "after", "groups: {'0': {'a/b~': [{id: b}, {id: a}, {id: new}]}}")
|
||||||
|
matching := buildResolutionCorrespondence(before, after, policy)
|
||||||
|
parsed, err := ParsePath("/groups/0/a~1b~0/0/field")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{destination.Index(0).Key("field"), parsed} {
|
||||||
|
entry, found := matching.entryPath(path)
|
||||||
|
if !found || entry.String() != destination.Index(1).Key("field").String() {
|
||||||
|
t.Fatalf("entry = %s, %t", entry.String(), found)
|
||||||
|
}
|
||||||
|
final, found := matching.finalPath(entry)
|
||||||
|
if !found || final.String() != path.String() {
|
||||||
|
t.Fatalf("round trip = %s, %t", final.String(), found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []Path{
|
||||||
|
Keys("groups").Index(0).Key("a/b~").Index(0),
|
||||||
|
destination.Key("0"), destination.Index(8), destination,
|
||||||
|
} {
|
||||||
|
entry, found := matching.entryPath(path)
|
||||||
|
if !found || entry.String() != path.String() {
|
||||||
|
t.Fatalf("positional path %s became %s, %t", path.String(), entry.String(), found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, found := matching.entryPath(destination.Index(2)); found {
|
||||||
|
t.Fatal("new identity acquired entry evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,11 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
|
|||||||
classifyPath(resolved)
|
classifyPath(resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return closeSecretDependencies(paths, secret, dependencies, inherited)
|
var correspondence resolutionCorrespondence
|
||||||
|
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
|
||||||
|
correspondence = model.resolutionPayload.correspondence
|
||||||
|
}
|
||||||
|
return closeSecretDependenciesWithCorrespondence(paths, secret, dependencies, inherited, correspondence)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSecretPath(paths map[string]Path, pointer string) {
|
func addSecretPath(paths map[string]Path, pointer string) {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ type secretTrigger struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
|
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
|
||||||
|
return closeSecretDependenciesWithCorrespondence(paths, sensitive, dependencies, inherited, resolutionCorrespondence{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matching adds endpoint edges to the same work queue as recorded dependencies.
|
||||||
|
// Newly reached coordinates are indexed once, including historical fields that
|
||||||
|
// have no physical descendant at the corresponding final position.
|
||||||
|
func closeSecretDependenciesWithCorrespondence(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency, correspondence resolutionCorrespondence) map[string]bool {
|
||||||
result := make(map[string]bool, len(sensitive))
|
result := make(map[string]bool, len(sensitive))
|
||||||
queue := make([]string, 0, len(sensitive))
|
queue := make([]string, 0, len(sensitive))
|
||||||
for pointer, classified := range sensitive {
|
for pointer, classified := range sensitive {
|
||||||
@@ -17,12 +24,14 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
|||||||
|
|
||||||
var triggers pathPrefixIndex
|
var triggers pathPrefixIndex
|
||||||
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
|
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
|
||||||
|
fired := make([]bool, 0, cap(triggerValues))
|
||||||
insert := func(dependency Path, target string, ordinary bool) {
|
insert := func(dependency Path, target string, ordinary bool) {
|
||||||
if ordinary && !dependency.valid() {
|
if ordinary && !dependency.valid() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := len(triggerValues)
|
id := len(triggerValues)
|
||||||
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
|
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
|
||||||
|
fired = append(fired, false)
|
||||||
triggers.insert(dependency.String(), id)
|
triggers.insert(dependency.String(), id)
|
||||||
}
|
}
|
||||||
for _, dependency := range dependencies {
|
for _, dependency := range dependencies {
|
||||||
@@ -33,7 +42,46 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
|||||||
insert(link.target, link.dependency.String(), false)
|
insert(link.target, link.dependency.String(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fired := make([]bool, len(triggerValues))
|
var projected map[string]bool
|
||||||
|
if len(correspondence.indexedCollections) != 0 {
|
||||||
|
projected = make(map[string]bool, len(paths))
|
||||||
|
}
|
||||||
|
project := func(path Path) {
|
||||||
|
if projected == nil || !path.valid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pointer := path.String()
|
||||||
|
if projected[pointer] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projected[pointer] = true
|
||||||
|
add := func(target Path, matched bool) {
|
||||||
|
if !matched {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetPointer := target.String()
|
||||||
|
if targetPointer == pointer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := paths[targetPointer]; !exists {
|
||||||
|
paths[targetPointer] = target
|
||||||
|
}
|
||||||
|
insert(path, targetPointer, false)
|
||||||
|
}
|
||||||
|
add(correspondence.finalPath(path))
|
||||||
|
add(correspondence.entryPath(path))
|
||||||
|
}
|
||||||
|
if projected != nil {
|
||||||
|
// Projection may add missing target coordinates to paths. Snapshot the
|
||||||
|
// original candidates so map insertion cannot affect this traversal.
|
||||||
|
candidates := make([]Path, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
candidates = append(candidates, path)
|
||||||
|
}
|
||||||
|
for _, path := range candidates {
|
||||||
|
project(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
activate := func(id int) {
|
activate := func(id int) {
|
||||||
if fired[id] {
|
if fired[id] {
|
||||||
return
|
return
|
||||||
@@ -48,6 +96,9 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
|||||||
for head := 0; head < len(queue); head++ {
|
for head := 0; head < len(queue); head++ {
|
||||||
pointer := queue[head]
|
pointer := queue[head]
|
||||||
if classified, ok := paths[pointer]; ok && classified.valid() {
|
if classified, ok := paths[pointer]; ok && classified.valid() {
|
||||||
|
// Register a synthesized coordinate before visiting triggers, never
|
||||||
|
// while a trigger-index traversal is in progress.
|
||||||
|
project(classified)
|
||||||
triggers.visitAncestors(classified.String(), func(id int) {
|
triggers.visitAncestors(classified.String(), func(id int) {
|
||||||
if triggerValues[id].ordinary {
|
if triggerValues[id].ordinary {
|
||||||
activate(id)
|
activate(id)
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ type Override struct {
|
|||||||
|
|
||||||
// Resolution lets a resolver inspect supplied values and record derived defaults
|
// Resolution lets a resolver inspect supplied values and record derived defaults
|
||||||
// or normalization. Its input queries describe the state at callback entry,
|
// or normalization. Its input queries describe the state at callback entry,
|
||||||
// even after the resolver changes the configuration. Use the context only during
|
// even after the resolver changes the configuration. Records and helpers use
|
||||||
|
// final paths; helpers do not follow moved records. Use the context only during
|
||||||
// the callback; do not retain it or use it concurrently.
|
// the callback; do not retain it or use it concurrently.
|
||||||
type Resolution struct{ value *engine.Resolution }
|
type Resolution struct{ value *engine.Resolution }
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ func (m Metadata) IsExplicit(path Path) bool {
|
|||||||
// History returns detached definition, inheritance, and resolution records for path. A record's
|
// History returns detached definition, inheritance, and resolution records for path. A record's
|
||||||
// Path can be an ancestor when that container operation replaced or removed path.
|
// Path can be an ancestor when that container operation replaced or removed path.
|
||||||
// Removed historical paths can have history even though Lookup returns false.
|
// Removed historical paths can have history even though Lookup returns false.
|
||||||
|
// Surviving registered records follow their matched resolution-entry history;
|
||||||
|
// collection queries retain removed evidence at physical positions.
|
||||||
// Unknown and invalid paths return nil.
|
// Unknown and invalid paths return nil.
|
||||||
func (m Metadata) History(path Path) []Change {
|
func (m Metadata) History(path Path) []Change {
|
||||||
values := m.value.History(toEnginePath(path))
|
values := m.value.History(toEnginePath(path))
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git",
|
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git",
|
||||||
"validation": {
|
"validation": {
|
||||||
"forbidden_files": [".releasekit/gitea-token"],
|
"forbidden_files": [".releasekit/gitea-token"],
|
||||||
|
"markdown": {},
|
||||||
|
"module_distribution": {
|
||||||
|
"local_replacements": true
|
||||||
|
},
|
||||||
"go": {
|
"go": {
|
||||||
"race": true,
|
"race": true,
|
||||||
"full_vet": true,
|
"full_vet": true,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ func SequenceAnyMappingKey() SequenceSelectorSegment {
|
|||||||
|
|
||||||
// SequenceIdentity records the identity fields used for strict sequence
|
// SequenceIdentity records the identity fields used for strict sequence
|
||||||
// composition, identified-record parent lookup, and final typed-result
|
// composition, identified-record parent lookup, and final typed-result
|
||||||
// validation during loading.
|
// validation during loading. Eligible entry and final records also correspond
|
||||||
|
// during resolution auditing and inspection.
|
||||||
type SequenceIdentity struct {
|
type SequenceIdentity struct {
|
||||||
exactPath bool
|
exactPath bool
|
||||||
path Path
|
path Path
|
||||||
|
|||||||
Reference in New Issue
Block a user