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
|
||||
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
|
||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
the application to enumerate mapping keys. Each strict tier has independent
|
||||
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.
|
||||
|
||||
Private structural comparison operates on canonical trees. Mapping additions,
|
||||
removals, and descendant replacements retain semantic paths. A sequence is atomic:
|
||||
any sequence change reports the sequence path and never invents correspondence
|
||||
between old and new indices. Cross-kind and null/container changes replace the
|
||||
affected path.
|
||||
removals, and descendant replacements retain semantic paths. The shared
|
||||
comparison used by effective diff treats sequences atomically. Resolution uses
|
||||
its separate positional and registered-identity comparison described above.
|
||||
Cross-kind and null/container changes replace the affected path.
|
||||
|
||||
These mechanisms support decoding, resolution auditing, metadata,
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
- [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)
|
||||
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
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
|
||||
|
||||
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,
|
||||
implementation plan, and resolved remediation record are preserved in Git history
|
||||
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>`.
|
||||
|
||||
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.
|
||||
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||
current-versus-planned claims; automated checks do not replace content review.
|
||||
|
||||
Use focused checks while iterating, with validation proportionate to the change
|
||||
under the testing policy. Documentation-only work normally needs the local-link
|
||||
check below and whitespace review; behavior-bearing examples and commands also
|
||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||
under the testing policy. Documentation-only work normally needs review of
|
||||
affected links and whitespace; behavior-bearing examples and commands also
|
||||
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
|
||||
its selected validation through one `publish` invocation. The everyday checker
|
||||
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` |
|
||||
| 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'` |
|
||||
| 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'` |
|
||||
| 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'` |
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 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$'` |
|
||||
|
||||
## Fuzz validation
|
||||
|
||||
@@ -50,7 +50,7 @@ Implemented component owners:
|
||||
- Engine mechanism tests own source, composition, inheritance, decoding,
|
||||
callback, provenance,
|
||||
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
|
||||
`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
|
||||
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
||||
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)
|
||||
freezes that builder and counts the retained document roots before secret lookup
|
||||
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
|
||||
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
|
||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||
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
|
||||
once with the race detector and test-time vet disabled. The suite already
|
||||
includes examples, consumer fixtures, documentation links, and package
|
||||
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
||||
includes examples, consumer fixtures, and package boundaries. One full vet
|
||||
pass adds static checks beyond the test-time subset.
|
||||
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||
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
|
||||
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
|
||||
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||
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,
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
|
||||
the checkout.
|
||||
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
||||
commands never install another ReleaseKit version:
|
||||
Run the module-selected ReleaseKit tool from the repository root. Go builds
|
||||
and caches the executable; no temporary executable or launcher is needed.
|
||||
These commands use the version recorded in `go.mod`:
|
||||
|
||||
```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
|
||||
"$releasekit_bin" version
|
||||
go tool releasekit version
|
||||
```
|
||||
|
||||
This repository selects ReleaseKit v0.6.0. Use the compiled program above for
|
||||
publication so its exit status and publication report reach you unchanged.
|
||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||
This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the
|
||||
tool's exit status and publication report; `go run` maps a nonzero child exit
|
||||
status to 1. ReleaseKit's child Go commands
|
||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||
executable first on `PATH` through the publication command.
|
||||
|
||||
@@ -174,10 +171,10 @@ candidate to `main`, and invoke validation again.
|
||||
## 4. Publish the committed candidate
|
||||
|
||||
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
|
||||
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
||||
go tool releasekit publish vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
publishing and is not required before `publish`, which performs its own checks:
|
||||
|
||||
```sh
|
||||
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
||||
go tool releasekit check vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
compiled executable from step 1. For the first release using this workflow:
|
||||
root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the
|
||||
module-selected tool as in step 1. For the first release using this workflow:
|
||||
|
||||
```sh
|
||||
"$releasekit_bin" deliver v1.0.0
|
||||
go tool releasekit deliver v1.0.0
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
original tag, fetching that tag if needed. Build the module-selected CLI from
|
||||
that checkout using step 1's tool-build commands, without changing the tagged
|
||||
original tag, fetching that tag if needed. Use that checkout's module-selected
|
||||
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
|
||||
available locally:
|
||||
|
||||
@@ -260,7 +263,7 @@ git switch --detach "$release_version"
|
||||
After preparing the tool in that tagged checkout:
|
||||
|
||||
```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.
|
||||
@@ -269,53 +272,80 @@ version through the normal release procedure.
|
||||
|
||||
## 6. Recover from a failed or uncertain tag push
|
||||
|
||||
The executable reports the publication state, phase, version, candidate commit,
|
||||
tag-object ID, effective endpoint, and error on standard error. After a failed
|
||||
tag push, it makes one bounded check of the exact publication remote to find out
|
||||
whether the tag arrived. Exit status 3
|
||||
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
||||
ReleaseKit attempts to save publication evidence before pushing a tag and
|
||||
reports the absolute record path, or a recording warning, separately from the
|
||||
publication outcome. A recording failure does not undo a successful publication
|
||||
or change its exit status. Confirmed publication still completes the source
|
||||
release; hosted delivery remains a separate operation.
|
||||
|
||||
An annotated tag is a Git object containing the release note and a pointer to
|
||||
the source commit. Its tag-object ID identifies that complete object. Checking
|
||||
only the commit ID would not establish that the intended tag object arrived.
|
||||
Records default to a private store under the repository's Git common directory,
|
||||
shared by linked worktrees. `publish --record-dir DIR` can select another
|
||||
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,
|
||||
version, and tag-object ID from the report. Copy the captured endpoint into the variable
|
||||
below using normal shell quoting. Check that Git still resolves it to itself,
|
||||
then query the authoritative refs once:
|
||||
After a failed push, ReleaseKit makes one bounded observation of the original
|
||||
publication endpoint. If the outcome remains unknown, use the explicitly saved
|
||||
record rather than rerunning publication. Copy the recovery command printed by
|
||||
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
|
||||
authoritative='COPY_CAPTURED_ENDPOINT'
|
||||
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"
|
||||
"$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
|
||||
candidate there. Correct it with a new commit rather than rewriting or rolling
|
||||
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.
|
||||
candidate there; correct it in a new commit rather than rewriting `main`.
|
||||
|
||||
## 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 (
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||
go.maximumdirect.net/releasekit v0.6.0 h1:wbzMeQGeLjXigBSN9j9iJLf/CC3htjzt9iEwubezgy0=
|
||||
go.maximumdirect.net/releasekit v0.6.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||
go.maximumdirect.net/releasekit v0.8.0 h1:hSaHPbUI7tAhnolfbu7UMj2fvmm9FGBY+jhkygdbxR0=
|
||||
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/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
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
|
||||
// order: inheritance appears after source combination and before the transition
|
||||
// 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) {
|
||||
if r == nil {
|
||||
return Explanation{}, false
|
||||
|
||||
@@ -2,8 +2,9 @@ package engine
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"go.maximumdirect.net/configkit/internal/document"
|
||||
"slices"
|
||||
|
||||
"go.maximumdirect.net/configkit/internal/document"
|
||||
)
|
||||
|
||||
type documentDeltaKind uint8
|
||||
@@ -24,6 +25,10 @@ type documentDelta struct {
|
||||
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||
var deltas []documentDelta
|
||||
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||
return sortDocumentDeltas(deltas)
|
||||
}
|
||||
|
||||
func sortDocumentDeltas(deltas []documentDelta) []documentDelta {
|
||||
type orderedDelta struct {
|
||||
delta documentDelta
|
||||
pointer string
|
||||
|
||||
@@ -49,6 +49,11 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
return Explanation{}, false
|
||||
}
|
||||
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)}
|
||||
if info, ok := r.metadata.Lookup(path); ok {
|
||||
explanation.Origin = cloneOrigin(info.Origin)
|
||||
@@ -57,7 +62,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
working := document.Clone(model.baseline)
|
||||
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
After: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshot(working, entryPath, r.secrets),
|
||||
Origin: cloneOrigin(baseline.attribution.origin),
|
||||
})
|
||||
}
|
||||
@@ -67,13 +72,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
continue
|
||||
}
|
||||
application := payload.application
|
||||
before := valueSnapshot(working, path, r.secrets)
|
||||
before := valueSnapshot(working, entryPath, r.secrets)
|
||||
applyHistoryApplication(&working, application, replayIndex)
|
||||
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
after := valueSnapshot(working, entryPath, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
@@ -83,20 +88,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
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:
|
||||
// decoding applies them to the final input tree. Show that reconciliation
|
||||
// explicitly before typed CLI contributions and domain resolution.
|
||||
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
|
||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
decoded, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
||||
replayed, _ := documentAtPath(working, entryPath)
|
||||
decoded, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
|
||||
if selected && !configurationValuesEqual(replayed, decoded) {
|
||||
origin, reasons := checkpointExplanationAttribution(selection)
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
||||
Before: valueSnapshot(working, entryPath, r.secrets),
|
||||
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
|
||||
Origin: origin,
|
||||
Reasons: reasons,
|
||||
})
|
||||
@@ -109,13 +114,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
continue
|
||||
}
|
||||
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
|
||||
before := valueSnapshot(working, path, r.secrets)
|
||||
before := valueSnapshot(working, entryPath, r.secrets)
|
||||
applyHistoryApplication(&working, payload.application, replayIndex)
|
||||
selected, relevant := selections.application(applicationRef)
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
after := valueSnapshot(working, entryPath, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
@@ -127,13 +132,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
}
|
||||
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
|
||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
||||
replayed, _ := documentAtPath(working, entryPath)
|
||||
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
|
||||
if selected && !configurationValuesEqual(replayed, effectiveInput) {
|
||||
origin, reasons := checkpointExplanationAttribution(selection)
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
||||
Before: valueSnapshot(working, entryPath, r.secrets),
|
||||
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
|
||||
Origin: origin,
|
||||
Reasons: reasons,
|
||||
})
|
||||
@@ -147,7 +152,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
|
||||
@@ -12,15 +12,16 @@ type resolutionReplayGroup struct {
|
||||
}
|
||||
|
||||
type historyResolutionPayload struct {
|
||||
snapshots []resolutionSnapshot
|
||||
replayGroups []resolutionReplayGroup
|
||||
snapshots []resolutionSnapshot
|
||||
replayGroups []resolutionReplayGroup
|
||||
correspondence resolutionCorrespondence
|
||||
}
|
||||
|
||||
// observeResolutionEvidence retains audited records in producer order and
|
||||
// indexes the concrete shapes in every original before/after subtree. Snapshot
|
||||
// roots are already detached by runResolution and are shared with the replay
|
||||
// group that refers to their covering record.
|
||||
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot) {
|
||||
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot, correspondence ...resolutionCorrespondence) {
|
||||
builder.requireOpen()
|
||||
if len(snapshots) == 0 {
|
||||
return
|
||||
@@ -36,10 +37,14 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
|
||||
snapshots: make([]resolutionSnapshot, len(snapshots)),
|
||||
replayGroups: coalescedResolutionSnapshots(snapshots),
|
||||
}
|
||||
if len(correspondence) != 0 {
|
||||
payload.correspondence = correspondence[0].clone()
|
||||
}
|
||||
attributions := make([]historyAttributionRef, len(snapshots))
|
||||
lookup := buildDocumentLookup(builder.scratch)
|
||||
for recordID, snapshot := range snapshots {
|
||||
snapshot.change = cloneChange(snapshot.change)
|
||||
snapshot.entryPath = snapshot.entryPath.clone()
|
||||
payload.snapshots[recordID] = snapshot
|
||||
|
||||
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)
|
||||
attributions[recordID] = attribution
|
||||
beforePath, beforeFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup)
|
||||
if beforeFound {
|
||||
entryPath, hasEntry := snapshot.change.Path, true
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,21 +26,43 @@ type historySensitivityCandidate struct {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
candidateSet := make(map[int]struct{})
|
||||
for _, observationID := range model.observationsByPointer[path.String()] {
|
||||
candidateSet[observationID] = struct{}{}
|
||||
collect := func(query Path) {
|
||||
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
|
||||
// ancestor replacements; descendants cover operations within an enclosing
|
||||
// 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))
|
||||
for observationID := range candidateSet {
|
||||
candidates = append(candidates, observationID)
|
||||
@@ -54,18 +76,30 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
|
||||
if observation.role == historyRoleInventory {
|
||||
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
|
||||
attributionRef := observation.attributionRef
|
||||
if observation.role == historyRoleContribution {
|
||||
if !historyObservationMatchesQuery(path, observation.path) {
|
||||
if !historyObservationMatchesQuery(query, observation.path) {
|
||||
continue
|
||||
}
|
||||
application, found := model.applicationPayload(observation.payload)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if pathContains(observation.path, path) && len(observation.path.segments) < len(path.segments) {
|
||||
local, ok := historyApplicationLocalPath(application, path)
|
||||
if pathContains(observation.path, query) && len(observation.path.segments) < len(query.segments) {
|
||||
local, ok := historyApplicationLocalPath(application, query)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -73,12 +107,17 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant {
|
||||
if operationPath, relevant := applicationOperationPath(application, query, model.documentLookup); relevant {
|
||||
reportedPath = operationPath
|
||||
}
|
||||
} else if !historyObservationMatchesQuery(path, observation.path) {
|
||||
} else if !historyObservationMatchesQuery(query, observation.path) {
|
||||
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]
|
||||
if !found {
|
||||
selectionIndex = len(selections)
|
||||
|
||||
@@ -317,7 +317,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
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...)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
|
||||
@@ -141,6 +141,15 @@ func cloneMetadata(metadata Metadata) Metadata {
|
||||
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) {
|
||||
finalPaths := make(map[string]*document.Node)
|
||||
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)
|
||||
var deltaCoverage pathPrefixIndex
|
||||
snapshotIDs := make([]int, len(resolved.deltas))
|
||||
@@ -163,7 +172,7 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entr
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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 {
|
||||
if _, ok := finalPaths[pointer]; !ok {
|
||||
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) {
|
||||
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 {
|
||||
info.Explicit = entryFound && entryInfo.Explicit
|
||||
info.Null = node.Kind == document.Null
|
||||
|
||||
@@ -43,7 +43,7 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
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
|
||||
decoded := prepared.decoded
|
||||
configuration := decoded.value.Interface().(T)
|
||||
@@ -54,7 +54,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
var resolutionSnapshots []resolutionSnapshot
|
||||
refreshReferenceInput := false
|
||||
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...)
|
||||
if hasErrors(resolved.diagnostics) {
|
||||
return resolvedLoad[T]{}, diagnostics
|
||||
@@ -62,7 +62,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
final = resolved.final
|
||||
resolutionSnapshots = resolved.snapshots
|
||||
if builder != nil && len(resolutionSnapshots) != 0 {
|
||||
builder.observeResolutionEvidence(resolutionSnapshots)
|
||||
builder.observeResolutionEvidence(resolutionSnapshots, resolved.correspondence)
|
||||
}
|
||||
applyResolutionMetadata(&metadata, resolved, prepared.resolution)
|
||||
finalizeEffectiveMetadataPaths(&metadata, final)
|
||||
|
||||
@@ -49,7 +49,7 @@ func (m Metadata) effectiveField(path Path) (FieldInfo, bool) {
|
||||
// Unknown and invalid paths return nil.
|
||||
func (m Metadata) History(path Path) []Change {
|
||||
model := m.historyEvidence()
|
||||
if model == nil || !model.hasObservedShape(path) {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
selections := model.selectHistory(path)
|
||||
|
||||
@@ -292,7 +292,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
||||
value.Items[0] = "normalized"
|
||||
resolution.RecordNormalization(Keys("items"), "normalized item")
|
||||
return nil
|
||||
}, schema, decoded, document, input)
|
||||
}, schema, decoded, document, input, sequencePolicy{})
|
||||
if len(resolved.diagnostics) != 0 {
|
||||
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
||||
}
|
||||
|
||||
@@ -162,6 +162,13 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb
|
||||
}
|
||||
var lookup *documentLookup
|
||||
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 {
|
||||
lookup = buildDocumentLookup(fallbackRoot)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
||||
value.Count = 6
|
||||
resolution.RecordNormalization(Keys("count"), "normalize count")
|
||||
return nil
|
||||
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned))
|
||||
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned), sequencePolicy{})
|
||||
if len(resolveDiagnostics) != 0 {
|
||||
t.Fatalf("resolveLoadInput() diagnostics = %#v", resolveDiagnostics)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
||||
resolution.RecordNormalization(Keys("api_key"), "select effective secret reference")
|
||||
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 {
|
||||
t.Fatalf("resolution = %#v, configuration = %#v", resolved, configuration)
|
||||
}
|
||||
|
||||
@@ -59,16 +59,19 @@ func (record *resolutionRecord) addDependency(path Path) {
|
||||
}
|
||||
|
||||
type resolutionSnapshot struct {
|
||||
change Change
|
||||
before *document.Node
|
||||
after *document.Node
|
||||
change Change
|
||||
before *document.Node
|
||||
after *document.Node
|
||||
entryPath Path
|
||||
hasEntry bool
|
||||
}
|
||||
|
||||
type resolutionResult struct {
|
||||
final *document.Node
|
||||
deltas []documentDelta
|
||||
snapshots []resolutionSnapshot
|
||||
diagnostics []Diagnostic
|
||||
final *document.Node
|
||||
deltas []documentDelta
|
||||
snapshots []resolutionSnapshot
|
||||
diagnostics []Diagnostic
|
||||
correspondence resolutionCorrespondence
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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{
|
||||
active: true, schema: schema, document: input,
|
||||
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")
|
||||
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)
|
||||
coverage := newResolutionCoverageIndexFromRecords(ordered)
|
||||
beforeLookup := buildDocumentLookup(decoded.final)
|
||||
@@ -324,7 +328,9 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
||||
}
|
||||
}
|
||||
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)
|
||||
if !beforePresent && !afterPresent {
|
||||
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))
|
||||
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)
|
||||
snapshots = append(snapshots, resolutionSnapshot{
|
||||
change: Change{
|
||||
@@ -349,11 +359,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
||||
Reasons: append([]string(nil), record.reasons...),
|
||||
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{
|
||||
final: after, deltas: deltas, snapshots: snapshots,
|
||||
final: after, deltas: deltas, snapshots: snapshots, correspondence: correspondence,
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,13 @@ type secretTrigger struct {
|
||||
}
|
||||
|
||||
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))
|
||||
queue := make([]string, 0, len(sensitive))
|
||||
for pointer, classified := range sensitive {
|
||||
@@ -17,12 +24,14 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
||||
|
||||
var triggers pathPrefixIndex
|
||||
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
|
||||
fired := make([]bool, 0, cap(triggerValues))
|
||||
insert := func(dependency Path, target string, ordinary bool) {
|
||||
if ordinary && !dependency.valid() {
|
||||
return
|
||||
}
|
||||
id := len(triggerValues)
|
||||
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
|
||||
fired = append(fired, false)
|
||||
triggers.insert(dependency.String(), id)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if fired[id] {
|
||||
return
|
||||
@@ -48,6 +96,9 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
||||
for head := 0; head < len(queue); head++ {
|
||||
pointer := queue[head]
|
||||
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) {
|
||||
if triggerValues[id].ordinary {
|
||||
activate(id)
|
||||
|
||||
@@ -140,7 +140,8 @@ type Override struct {
|
||||
|
||||
// Resolution lets a resolver inspect supplied values and record derived defaults
|
||||
// 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.
|
||||
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
|
||||
// Path can be an ancestor when that container operation replaced or removed path.
|
||||
// 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.
|
||||
func (m Metadata) History(path Path) []Change {
|
||||
values := m.value.History(toEnginePath(path))
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git",
|
||||
"validation": {
|
||||
"forbidden_files": [".releasekit/gitea-token"],
|
||||
"markdown": {},
|
||||
"module_distribution": {
|
||||
"local_replacements": true
|
||||
},
|
||||
"go": {
|
||||
"race": true,
|
||||
"full_vet": true,
|
||||
|
||||
@@ -26,7 +26,8 @@ func SequenceAnyMappingKey() SequenceSelectorSegment {
|
||||
|
||||
// SequenceIdentity records the identity fields used for strict sequence
|
||||
// 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 {
|
||||
exactPath bool
|
||||
path Path
|
||||
|
||||
Reference in New Issue
Block a user