Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1c7e87f2c | |||
| 99098d2974 | |||
| 3e50388b73 | |||
| 22603f0e5b | |||
| 5f001cf597 | |||
| 47547bc63e | |||
| ce6005ca2d | |||
| ef420c943b | |||
| f0198f865d | |||
| 367fe4120f | |||
| 37ea670747 | |||
| 68de431bb9 | |||
| 720df98a5a | |||
| 81c27bb9cd | |||
| 9a08438769 | |||
| a05bb94a21 | |||
| d2608e8d56 | |||
| 18af2d2130 | |||
| bc3fc91296 | |||
| 08ea01921b | |||
| 761dd0a4e0 | |||
| b93cbb0639 | |||
| e47960d377 | |||
| f0a4bf9bb2 | |||
| 0a136063da | |||
| ae0c94d3c6 | |||
| c51bea9b4f | |||
| 132b9ec659 | |||
| c0bc5e2b73 | |||
| daa0cf9e88 | |||
| 2ba61d5c7c | |||
| e36740313d | |||
| 1960304f6f | |||
| 26ea24958c | |||
| 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.
|
||||
47
docs/adr/0022-main-relative-context.md
Normal file
47
docs/adr/0022-main-relative-context.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# ADR 0022: Bind Companion Contexts After Main Selection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Date
|
||||
|
||||
2026-11-09
|
||||
|
||||
## Context
|
||||
|
||||
Applications choose a main-file search and may also declare companion imports
|
||||
and a selected overlay. The main directory is only known after discovery, so
|
||||
supplying every dependent context before loading duplicates selection or requires
|
||||
a preliminary load. ADR 0019 keeps main discovery explicit and inside loading.
|
||||
|
||||
## Decision
|
||||
|
||||
Extend layered loading to bind application-declared non-file underlays to the
|
||||
resolved main context after acquisition. An explicit underlay context overrides
|
||||
the binding, and an explicit fallback or require-base option controls absence.
|
||||
Selected layered overlays choose an explicit context, the resolved main context,
|
||||
or a fallback in that order. Derived and fallback lookup use the context base;
|
||||
the inherited root continues to bound containment. Ordinary and explicit
|
||||
overlay lookup retain their established root-relative behavior.
|
||||
|
||||
Applications remain responsible for choosing searches, import declarations,
|
||||
and overlay names. Selection failures are terminal; no second search or
|
||||
application context callback is introduced.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Require applications to discover the main file separately. This repeats
|
||||
discovery and may run a complete load more than once.
|
||||
- Implicitly scan conventional companion directories. This would impose
|
||||
application-specific conventions without an explicit declaration.
|
||||
- Add only a relative file constructor. This would not cover mapping or bytes
|
||||
sources with existing import, placement, and composition rules.
|
||||
|
||||
## Consequences
|
||||
|
||||
The load owns the resolved binding and keeps namespace, containment, source
|
||||
identity, and handle lifetime in the existing acquisition lifecycle. A missing
|
||||
base skips optional imports while preserving the declaring payload; required
|
||||
imports and require-base retain explicit failures. The configuration contract
|
||||
and public declarations describe the observable rules.
|
||||
@@ -75,6 +75,8 @@ or the supported directory presets, then pass it to `SearchConfig`. Search
|
||||
descriptions are immutable and reusable; each load resolves dynamic directories
|
||||
and checks current candidate files again. `Searches` is ordered concatenation.
|
||||
It preserves the supplied description order and does not deduplicate candidates.
|
||||
Layered companion inputs can use the selected main's context without a second
|
||||
search, as described under [layered loading](#layered-loading).
|
||||
|
||||
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
||||
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
||||
@@ -316,13 +318,33 @@ typed configuration before application resolution. Syntax, strict-composition,
|
||||
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
||||
secret lookup, validation, and publication run once on the resulting values.
|
||||
|
||||
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
||||
independent of the main source's namespace. Its name and root-relative lookup
|
||||
follow the selected-overlay rules below; inactive overlays ignore the context.
|
||||
An active layered overlay selects `OverlayFilesystem` first, then the resolved
|
||||
main context, then `OverlayFallback`. An invalid selected context fails without
|
||||
trying another. An inactive overlay ignores both supplied contexts.
|
||||
The same path in a different filesystem namespace is not a repeated file.
|
||||
Ordinary `Load` infers overlay context from sources that provide one; with
|
||||
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
||||
|
||||
An underlay `Input.MainRelative` requests the resolved main context for a
|
||||
non-file source. An explicit `Input.Filesystem` overrides it entirely. File,
|
||||
native file, and search underlays cannot use an active binding, nor can the
|
||||
main input bind to itself. `MainRelative()` creates a fresh default description.
|
||||
The binding does not enable imports. If the main has no usable context, its
|
||||
`Fallback` is selected if supplied; otherwise optional imports are skipped and
|
||||
the input's own payload remains. Required named targets, directory and glob
|
||||
selections fail without a base, while an empty `path: []` remains a no-op.
|
||||
`RequireBase` requires a usable main or fallback context even without imports.
|
||||
Invalid declarations still fail, and skipped imports create no search evidence.
|
||||
Selected main failures never trigger fallback or a second discovery attempt.
|
||||
|
||||
Derived contexts preserve the main namespace and containment root, while lookup
|
||||
starts at its resolved base. A host file's base is its canonical target directory,
|
||||
including when the selected filename is a symlink. A wider root permits contained
|
||||
parent-relative imports without moving the lookup base. Explicit non-file and
|
||||
absent-main contexts can provide a base; an absent host input may use its root as
|
||||
base. An unused fallback is not opened or checked for existence. See the
|
||||
[consumer example](../examples/consumers/main_relative.go).
|
||||
|
||||
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||
history follows precedence order. Default main-version checking happens after
|
||||
@@ -567,7 +589,7 @@ one context for a composite origin or an application-derived value.
|
||||
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
|
||||
is its slash-relative directory inside that root. This includes imported
|
||||
sources, selected overlays, host files even without enabled imports, native
|
||||
files, and byte, reader, or mapping inputs with an explicit context. CLI and
|
||||
files, and byte, reader, or mapping inputs with an explicit or main-derived context. CLI and
|
||||
contextless synthetic inputs have no resource context. To open a relative
|
||||
resource, join `BaseDir` with the application resource name using slash-path
|
||||
operations and open that path through `FS`.
|
||||
@@ -772,6 +794,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 +1229,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 +1399,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
|
||||
@@ -1468,12 +1511,15 @@ claim records rather than mutating or aliasing an input document.
|
||||
`Options.Overlay` selects at most one overlay by name. Names must match
|
||||
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
||||
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
||||
For ordinary loads and explicit layered overlay contexts, every other name resolves
|
||||
to `<configuration-root>/overlays/<name>.yml`, with
|
||||
no extension fallback, case normalization, inheritance between overlay files,
|
||||
or overlay stack. A selected missing overlay is an error.
|
||||
For a layered main-derived or fallback context, lookup instead starts at the
|
||||
resolved base directory; the configured root still bounds containment.
|
||||
|
||||
With `NoSource`, the main input stays absent and the explicit filesystem context
|
||||
supplies the overlay root. The overlay is recorded as the first acquired source
|
||||
With ordinary `Load` and `NoSource`, the main input stays absent and the explicit
|
||||
filesystem context supplies the overlay root. The overlay is recorded as the first acquired source
|
||||
when no other source exists. Host contexts may use the root-only form described
|
||||
under [single-source loading](#single-source-loading); native contexts retain
|
||||
their complete namespace, base, and root requirements.
|
||||
@@ -1483,6 +1529,10 @@ base import graph is composed and its schema version is checked, but before defa
|
||||
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||
root and must not duplicate a file already present in the base graph.
|
||||
For native filesystems that implement `fs.ReadLinkFS`, these checks resolve
|
||||
link targets in the selected namespace. For other native filesystems, the
|
||||
checks use namespace-relative paths and reads follow that filesystem's
|
||||
ordinary behavior.
|
||||
|
||||
Collection destinations follow the
|
||||
[collection operation and presence rules](#strict-document-composition) above.
|
||||
@@ -1608,6 +1658,8 @@ config:
|
||||
Each selected document follows the same strict combination rules. A missing
|
||||
optional input can be absent; invalid declarations, unreadable selected files,
|
||||
and conflicting settings still fail loading.
|
||||
Main-relative underlays follow the same import rules once a context is selected;
|
||||
their no-base behavior is described under [layered loading](#layered-loading).
|
||||
|
||||
| Option | `path` | `dir` | `glob` |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -2056,10 +2108,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.
|
||||
|
||||
@@ -38,7 +38,8 @@ Start with one file, then choose the tasks your application needs:
|
||||
- Use `LoadLayered` when several inputs should supply lower-priority defaults
|
||||
beneath the main source. Each underlay group combines its inputs strictly.
|
||||
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
|
||||
import choice. The overlay filesystem context is selected independently in
|
||||
import choice. Set `Input.MainRelative` on a companion underlay to use the
|
||||
main input's filesystem context. Overlay context selection belongs to
|
||||
[`LayeredOptions`](../../layered.go).
|
||||
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||
@@ -53,6 +54,8 @@ file and layered cases. For import-focused adoption, see the
|
||||
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||
discovery composed with sequences, see the
|
||||
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||
For discovered main files with companion imports and an overlay, see the
|
||||
[main-relative example](../../examples/consumers/main_relative.go).
|
||||
|
||||
## Happy path
|
||||
|
||||
@@ -308,6 +311,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,8 @@ 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)
|
||||
- [Bind companion contexts after main selection](adr/0022-main-relative-context.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 +94,25 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
||||
|
||||
## Baseline validation
|
||||
|
||||
The completed main-relative companion roadmap, Audita request, implementation
|
||||
plan, and remediation report are preserved in Git history at `47547bc` under
|
||||
`docs/roadmap/`. The [layered loading contract](config.md#layered-loading)
|
||||
describes current behavior, and [ADR 0022](adr/0022-main-relative-context.md)
|
||||
records the context binding decision.
|
||||
|
||||
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 +271,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 +310,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 +319,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
|
||||
|
||||
@@ -20,6 +20,13 @@ patterns to discovered names, and form a set union of matching candidates across
|
||||
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||
candidate normalization. They do not suppress discovery or resource errors.
|
||||
|
||||
Layered assembly passes the resolved main context to underlay acquisition.
|
||||
Derived inputs retain the main's filesystem location and namespace. The
|
||||
receiving tier supplies its own host token and tracks repeated sources.
|
||||
Contextless bindings prepare declaration policies and assemble the declaring
|
||||
payload without opening filesystem access or recording selection evidence.
|
||||
Selected contexts use the existing acquisition and handle lifecycle.
|
||||
|
||||
Directory traversal tracks canonical directories on the active traversal path.
|
||||
It adds an identity when descending and removes it when the frame is popped, so
|
||||
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||
|
||||
@@ -11,7 +11,7 @@ Implemented component owners:
|
||||
explicitly selected configuration discovery. A search resolves once during
|
||||
shared acquisition; the selected concrete file or absent-input state then
|
||||
follows the existing composition, metadata, and resource-context lifecycle.
|
||||
It also owns grouped strict composition, recursive precedence assembly,
|
||||
It also owns main-relative context selection, grouped strict composition, recursive precedence assembly,
|
||||
per-input mapping and sequence-item placement, YAML collection declarations
|
||||
and operations for identified sequences, and application identity registrations for strict
|
||||
composition, inheritance, and final validation. It applies the selected
|
||||
@@ -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.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Standard Repository Policies
|
||||
|
||||
Material: `docs/policy/release.md`, adapted from
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
||||
Material: `docs/policy/documentation.md`, `docs/policy/oss.md`, and
|
||||
`docs/policy/release.md`, adapted from
|
||||
`b339ad0427a50e43a68b932a6a9f4669cc0f6252`; `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||
Policy Reading Guide in `docs/development.md`, adapted from
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
||||
@@ -16,12 +16,12 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252).
|
||||
|
||||
## go-yaml v4
|
||||
|
||||
|
||||
@@ -151,6 +151,11 @@ initialization, and resource checks. See the [configuration discovery
|
||||
contract](../config.md#configuration-discovery) and [ADR
|
||||
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||
|
||||
The layered loader may bind application-selected dependent locations after main
|
||||
selection. Applications still choose search conventions and must explicitly
|
||||
select an overlay. [ADR 0022](../adr/0022-main-relative-context.md) records
|
||||
the deferred context decision.
|
||||
|
||||
The engine applies each input's mapping destination within the existing source
|
||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||
retains each effective placement with source identity and inspection records;
|
||||
@@ -376,6 +381,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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Documentation Policy
|
||||
|
||||
**Base Policy Version:** 1.11
|
||||
**Base Policy Version:** 1.12
|
||||
|
||||
**Last Updated**: 2026-10-16
|
||||
**Last Updated**: 2026-11-09
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/documentation.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -91,7 +91,7 @@ secret values.
|
||||
| Consumer guidance for the public Go API | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | Go symbol contracts, configuration semantics, and internal implementation detail. |
|
||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||
| Architectural decision records | `docs/adr/` | Records of significant architectural decisions and supporting rationale. | Ordinary design choices, current behavior reference, implementation status, task sequencing. |
|
||||
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||
@@ -157,10 +157,16 @@ Use sequentially numbered ADR filenames such as
|
||||
|
||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||
|
||||
ADRs should be reserved for major architectural decisions, such as ownership
|
||||
boundaries, large-scale architectural features, or invariants that rise to
|
||||
the level of architectural significance. Routine choices, implementation
|
||||
decisions, and rejected suggestions do not warrant an ADR unless they
|
||||
otherwise represent a significant architectural decision.
|
||||
|
||||
Rejected architectural alternatives and significant architectural exclusions
|
||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
||||
represent a significant architectural decision.
|
||||
ideas.
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Open Source Licensing And Attribution Policy
|
||||
|
||||
**Base Policy Version:** 1.8
|
||||
**Base Policy Version:** 1.9
|
||||
|
||||
**Last Updated**: 2026-09-28
|
||||
**Last Updated**: 2026-11-09
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/oss.md)
|
||||
|
||||
## ConfigKit Applicability
|
||||
|
||||
@@ -74,8 +74,9 @@ individual packages.
|
||||
|
||||
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/application/COPYRIGHT)
|
||||
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/library/COPYRIGHT)
|
||||
to create the project’s root `COPYRIGHT` file.
|
||||
Use verified project names, contribution years, and copyright holders.
|
||||
to create the project’s root `COPYRIGHT` file. Use the examples as provided,
|
||||
without discretionary modification, unless otherwise instructed. Substitute
|
||||
verified project names, contribution years, and copyright holders.
|
||||
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
||||
text references together to reflect the author's selected grant.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Release Policy
|
||||
|
||||
**Base Policy Version:** 1.6
|
||||
**Base Policy Version:** 1.7
|
||||
|
||||
**Last Updated**: 2026-10-09
|
||||
**Last Updated**: 2026-11-09
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/release.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -110,6 +110,10 @@ For releases at or after `v1.0.0`:
|
||||
minimum as described below; and
|
||||
- a patch release may make backward-compatible fixes.
|
||||
|
||||
In this context, "backward-compatible" means only that pre-existing, valid
|
||||
configurations will continue to work following the release. Behavior need not
|
||||
remain strictly identical.
|
||||
|
||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||
configuration, data, integration, or compatibility contract when the release
|
||||
note explains the impact and required consumer action. A patch release must
|
||||
@@ -213,8 +217,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 +235,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)
|
||||
54
docs/releases/v1.2.0.md
Normal file
54
docs/releases/v1.2.0.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ConfigKit v1.2.0
|
||||
|
||||
`LoadLayered` can now use the resolved main configuration's filesystem context
|
||||
to find companion imports and the selected overlay in a single load. For example,
|
||||
an application that discovers `/srv/campaign/config.yml` can import fragments
|
||||
from `/srv/campaign/prompts/` and apply
|
||||
`/srv/campaign/overlays/production.yml`, regardless of its working directory.
|
||||
|
||||
## Main-relative companion inputs
|
||||
|
||||
Set `Input.MainRelative` to `MainRelative()` on a non-file underlay to use the
|
||||
main input's resolved context. Enable imports separately and declare the files
|
||||
or directories to load. A concrete `Input.Filesystem` takes precedence over the
|
||||
binding. The derived context keeps the main input's filesystem namespace and
|
||||
containment root, with lookup starting at its resolved base directory.
|
||||
|
||||
When the main supplies no usable context, the companion can use a
|
||||
`MainRelativeContext.Fallback`. Otherwise, optional imports are skipped and
|
||||
the companion's own configuration still contributes. Required imports that
|
||||
need targets fail, and `RequireBase` requires a usable context even when imports
|
||||
are disabled. Skipped imports produce no filesystem search evidence.
|
||||
|
||||
The consumer example combines embedded defaults, operator imports, and a selected
|
||||
overlay. [Example](../../examples/consumers/main_relative.go) ·
|
||||
[Layered loading contract](../config.md#layered-loading)
|
||||
|
||||
## Overlay lookup and filesystem checks
|
||||
|
||||
`LoadLayered` now looks for a selected overlay using `OverlayFilesystem`, the
|
||||
resolved main context, or `OverlayFallback`, in that order. Derived and fallback
|
||||
lookups start at the context's base directory. Explicit overlay contexts and
|
||||
ordinary `Load` use their existing root-relative lookup. A selected overlay
|
||||
must exist in the chosen context.
|
||||
|
||||
Native overlays resolve symlink targets when the filesystem exposes link
|
||||
inspection. This catches targets outside the configured root and aliases of
|
||||
already acquired files in the same namespace. Derived standalone host contexts
|
||||
now check root access the same way explicit contexts do. [Overlay contract](../config.md#selected-overlays)
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
Use keyed Go literals when constructing `Input` and `LayeredOptions`. Both
|
||||
types gained fields, so existing positional literals need updating. The minimum
|
||||
consumer Go version is 1.27.0.
|
||||
|
||||
Internally, imports with and without a filesystem base share root placement
|
||||
and accounting. Directory import policies reuse default patterns, and companions
|
||||
in the same tier share one derived context.
|
||||
|
||||
The repository also adopted upstream documentation, licensing, and release
|
||||
policy updates, including clearer guidance on when to write an ADR and what
|
||||
compatibility means for existing configurations. [Repository policies](../policy/meta.md)
|
||||
|
||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/oss/attribution.md)
|
||||
@@ -102,3 +102,29 @@ string equality must not be presented as semantic equality.
|
||||
Keeping `Path` solely as an opaque query/building value remains an alternative.
|
||||
No current contract changes or implementation commitment follow from this entry;
|
||||
a comparison operation can be added compatibly later.
|
||||
|
||||
## 5. Single-file named overlays
|
||||
|
||||
Status: deferred. Consider supporting a single `overlays.yml` containing named
|
||||
overlay bodies alongside the existing
|
||||
[selected-overlay directory convention](../config.md#selected-overlays).
|
||||
This would let small applications keep their overlays in one file. Planning was
|
||||
tabled because the added semantics outweighed the immediate convenience; the
|
||||
draft is preserved at `3e50388:docs/roadmap/overlays.md`.
|
||||
|
||||
The draft selected a direct name-to-body mapping, strict peer composition when
|
||||
both storage forms contribute to the selected name, and file-level version
|
||||
checks using the application's existing rule and mode. These provide a starting
|
||||
point for reconsideration, not an implementation commitment.
|
||||
|
||||
### Questions to resolve before implementation
|
||||
|
||||
- Where should collection controls live: within each named body or at catalog
|
||||
level, and how should that interact with document-root control ownership?
|
||||
- How should configured version metadata paths coexist with overlay names,
|
||||
including nested paths and modes that do not check overlay versions?
|
||||
- Which semantic checks should apply to unselected bodies?
|
||||
- Should `.yaml` also be recognized, and how should multiple matching files
|
||||
interact?
|
||||
- How should catalog lookup be enabled without disrupting successful loads that
|
||||
previously ignored an adjacent `overlays.yml`?
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version testdata/main-relative-bundle
|
||||
var bundledFiles embed.FS
|
||||
|
||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||
|
||||
47
examples/consumers/main_relative.go
Normal file
47
examples/consumers/main_relative.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
// CompanionConfig demonstrates independently bundled and operator prompt tiers.
|
||||
type CompanionConfig struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Prompts []CompanionPrompt `yaml:"prompts"`
|
||||
}
|
||||
|
||||
type CompanionPrompt struct {
|
||||
ID string `yaml:"id"`
|
||||
Text string `yaml:"text"`
|
||||
Note string `yaml:"note"`
|
||||
}
|
||||
|
||||
// LoadCompanionConfig discovers a main file and binds operator imports and the
|
||||
// selected overlay to its directory while retaining the embedded catalog tier.
|
||||
func LoadCompanionConfig(mainPath, overlay string) (*configkit.Result[CompanionConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/main-relative-bundle")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle := configkit.NewFilesystem("bundled catalog", files)
|
||||
absolute, err := filepath.Abs(mainPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
declarations := map[string]any{"config": map[string]any{"imports": []any{
|
||||
map[string]any{"path": "prompts.yml", "destination": []any{"prompts"}, "placement": "sequence-item"},
|
||||
map[string]any{"dir": "operator", "recursive": true, "pattern": []any{"*.yml", "*.yaml"}, "destination": []any{"prompts"}, "placement": "sequence-item"},
|
||||
}}}
|
||||
main := configkit.SearchConfig("", configkit.Searches(configkit.SearchPaths(absolute)), configkit.SearchOptions{Required: true})
|
||||
return configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[CompanionConfig]{
|
||||
Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{Source: bundle.File("catalog.yml")}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator imports", declarations), Imports: true, MainRelative: configkit.MainRelative()}}},
|
||||
},
|
||||
Overlay: overlay,
|
||||
Definition: configkit.Definition[CompanionConfig]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")}},
|
||||
})
|
||||
}
|
||||
86
examples/consumers/main_relative_test.go
Normal file
86
examples/consumers/main_relative_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/configkit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_mainRelativeCompanions() {
|
||||
result, err := consumers.LoadCompanionConfig(fixture("main-relative", "main.yml"), "live")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Mode, result.Config.Prompts)
|
||||
// Output: overlay [{shared operator replacement } {bundled bundled text } {local local text transitive detail}]
|
||||
}
|
||||
|
||||
func TestMainRelativeStrictPeers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, contents := range map[string]string{
|
||||
"main.yml": "mode: main\n", "prompts.yml": "id: duplicate\ntext: first\n", "operator/second.yml": "id: duplicate\ntext: second\n",
|
||||
} {
|
||||
file := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file, []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "")
|
||||
if err == nil {
|
||||
t.Fatal("duplicate peer identities succeeded")
|
||||
}
|
||||
for _, d := range err.(*configkit.LoadError).Diagnostics() {
|
||||
if d.Code == configkit.CodeComposeConflict {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
|
||||
func TestMainRelativeOptionalResources(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
file, directory, empty bool
|
||||
count int
|
||||
}{
|
||||
{"neither", false, false, false, 2}, {"file", true, false, false, 3},
|
||||
{"directory", false, true, false, 3}, {"both", true, true, false, 4}, {"empty directory", false, false, true, 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("mode: main\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.file {
|
||||
if err := os.WriteFile(filepath.Join(root, "prompts.yml"), []byte("id: file\ntext: file\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tc.directory || tc.empty {
|
||||
if err := os.Mkdir(filepath.Join(root, "operator"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tc.directory {
|
||||
if err := os.WriteFile(filepath.Join(root, "operator", "item.yaml"), []byte("id: directory\ntext: directory\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
result, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if len(result.Config.Prompts) != tc.count {
|
||||
t.Fatalf("prompts = %#v", result.Config.Prompts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
7
examples/consumers/testdata/main-relative-bundle/catalog.yml
vendored
Normal file
7
examples/consumers/testdata/main-relative-bundle/catalog.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
mode: embedded
|
||||
prompts:
|
||||
- id: shared
|
||||
text: embedded text
|
||||
note: replaced
|
||||
- id: bundled
|
||||
text: bundled text
|
||||
1
examples/consumers/testdata/main-relative/main.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/main.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
mode: main
|
||||
5
examples/consumers/testdata/main-relative/operator/nested/one.yaml
vendored
Normal file
5
examples/consumers/testdata/main-relative/operator/nested/one.yaml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
config:
|
||||
imports:
|
||||
- path: ../../shared/extra.yml
|
||||
id: local
|
||||
text: local text
|
||||
1
examples/consumers/testdata/main-relative/overlays/live.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/overlays/live.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
mode: overlay
|
||||
2
examples/consumers/testdata/main-relative/prompts.yml
vendored
Normal file
2
examples/consumers/testdata/main-relative/prompts.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
id: shared
|
||||
text: operator replacement
|
||||
1
examples/consumers/testdata/main-relative/shared/extra.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/shared/extra.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
note: transitive detail
|
||||
253
facade_main_relative_integration_test.go
Normal file
253
facade_main_relative_integration_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestMainRelativeStandaloneRejectsInaccessibleHostRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mainPath := filepath.Join(root, "main.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("count: 1\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(root, 0100); err != nil {
|
||||
t.Skipf("cannot set search-only permissions: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chmod(root, 0700); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
handle, err := os.OpenRoot(root)
|
||||
if err == nil {
|
||||
_ = handle.Close()
|
||||
t.Skip("host allows opening this search-only directory")
|
||||
}
|
||||
if !errors.Is(err, os.ErrPermission) {
|
||||
t.Fatalf("open root = %v, want permission denied", err)
|
||||
}
|
||||
if _, err := os.ReadFile(mainPath); err != nil {
|
||||
t.Skipf("main must remain readable by name: %v", err)
|
||||
}
|
||||
|
||||
main := configkit.Input{Source: configkit.File(mainPath)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
filesystem *configkit.FilesystemContext
|
||||
}{
|
||||
{name: "derived"},
|
||||
{name: "explicit", filesystem: &configkit.FilesystemContext{Root: root, BaseDir: root}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
underlay := configkit.Input{
|
||||
Source: configkit.Bytes("companion", []byte("{}\n")),
|
||||
MainRelative: &configkit.MainRelativeContext{RequireBase: true},
|
||||
Filesystem: tc.filesystem,
|
||||
}
|
||||
_, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("error = %v, want a load error", err)
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportPermission {
|
||||
t.Fatalf("diagnostics = %#v, want import.permission", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeDiscoveryAndEvidence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first, second := filepath.Join(root, "first"), filepath.Join(root, "second")
|
||||
for _, dir := range []string{first, second} {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 1\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "fragment.yml"), []byte("settings: {selected: "+filepath.Base(dir)+"}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
binding := configkit.MainRelative()
|
||||
underlay := configkit.Input{Source: configkit.Bytes("declaration", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: binding}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
{"first", []string{filepath.Join(first, "main.yml"), filepath.Join(second, "main.yml")}, "first"},
|
||||
{"later", []string{filepath.Join(root, "missing.yml"), filepath.Join(second, "main.yml")}, "second"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var callbacks int
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(test.paths...))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { callbacks++ }}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Settings["selected"] != test.want || callbacks != 1 {
|
||||
t.Fatalf("config = %#v, callbacks = %d", result.Config, callbacks)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[0].Layer != configkit.LayerMain || sources[1].Layer != configkit.LayerUnderlay || sources[2].Layer != configkit.LayerUnderlay || sources[2].UnderlayIndex != 1 || filepath.Base(sources[2].Path) != "fragment.yml" || sources[1].Path != "" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
if _, ok := result.SourceContext(sources[1].ID); !ok {
|
||||
t.Fatal("bound synthetic source lacks context")
|
||||
}
|
||||
if _, ok := result.SourceContext(sources[2].ID); !ok {
|
||||
t.Fatal("import source lacks context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeSyntheticMainContexts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("settings: {selected: synthetic}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||
mainSources := []configkit.Source{configkit.Bytes("main bytes", []byte("count: 1\n")), configkit.Reader("main reader", strings.NewReader("count: 1\n")), configkit.Mapping("main mapping", map[string]any{"count": 1})}
|
||||
for _, source := range mainSources {
|
||||
input := configkit.Input{Source: configkit.Bytes("underlay", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: source, Filesystem: ctx}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Settings["selected"] != "synthetic" {
|
||||
t.Fatalf("config = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeSelectedMainFailureIsTerminal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bad, good := filepath.Join(root, "bad.yml"), filepath.Join(root, "good.yml")
|
||||
if err := os.WriteFile(bad, []byte("count: [\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(good, []byte("count: 1\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
input := configkit.Input{Source: configkit.Bytes("underlay", []byte("count: 3\n")), MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}}
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(bad, good))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { called = true }}})
|
||||
if err == nil || called {
|
||||
t.Fatalf("error = %v, callback = %v", err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeSourceVersionEvidence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("version: 1\ncount: 1\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("version: 2\nsettings: {loaded: fragment}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := configkit.Input{Source: configkit.Bytes("companion", []byte("version: 3\nconfig: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()}
|
||||
type versioned struct {
|
||||
Version int `yaml:"version"`
|
||||
Count int `yaml:"count"`
|
||||
Settings map[string]string `yaml:"settings"`
|
||||
}
|
||||
definition := configkit.Definition[versioned]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(root, "main.yml"))}, configkit.LayeredOptions[versioned]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for i, want := range []string{"1", "3", "2"} {
|
||||
evidence := sources[i].VersionEvidence
|
||||
if evidence == nil || evidence.Version.Text != want || evidence.Location == nil || evidence.Location.SourceID != sources[i].ID {
|
||||
t.Fatalf("version evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeOptionalSearchExhaustion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "overlays"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"fragment.yml": "settings: {companion: loaded}\n",
|
||||
"overlays/live.yml": "count: 9\n",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
search := configkit.SearchConfig("", configkit.SearchPaths(filepath.Join(root, "absent.yml")))
|
||||
missing := filepath.Join(root, "missing-fallback")
|
||||
fallback := &configkit.FilesystemContext{Root: missing, BaseDir: missing}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mainContext *configkit.FilesystemContext
|
||||
bindingFallback *configkit.FilesystemContext
|
||||
overlayFallback *configkit.FilesystemContext
|
||||
overlay string
|
||||
wantCount int
|
||||
wantSources int
|
||||
}{
|
||||
{name: "skip without context", wantCount: 3, wantSources: 1},
|
||||
{
|
||||
name: "root-only main context wins", mainContext: &configkit.FilesystemContext{Root: root},
|
||||
bindingFallback: fallback, overlayFallback: fallback, overlay: "live", wantCount: 9, wantSources: 3,
|
||||
},
|
||||
{name: "selected missing companion fallback", bindingFallback: fallback},
|
||||
{name: "selected missing overlay fallback", overlay: "live", overlayFallback: fallback},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
underlay := configkit.Input{
|
||||
Source: configkit.Bytes("companion", []byte("count: 3\nconfig: {imports: [{path: fragment.yml}]}\n")),
|
||||
Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: tc.bindingFallback},
|
||||
}
|
||||
result, err := configkit.LoadLayered(
|
||||
configkit.Input{Source: search, Filesystem: tc.mainContext},
|
||||
configkit.LayeredOptions[layeredFacadeConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||
Overlay: tc.overlay, OverlayFallback: tc.overlayFallback,
|
||||
},
|
||||
)
|
||||
if tc.wantSources == 0 {
|
||||
if err == nil {
|
||||
t.Fatal("missing selected fallback succeeded")
|
||||
}
|
||||
diagnostics := err.(*configkit.LoadError).Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if result.Config.Count != tc.wantCount || len(sources) != tc.wantSources {
|
||||
t.Fatalf("config = %#v, sources = %#v", result.Config, sources)
|
||||
}
|
||||
if sources[0].Layer != configkit.LayerUnderlay || len(sources[0].EmptyImportSelections) != 0 {
|
||||
t.Fatalf("declaring source = %#v", sources[0])
|
||||
}
|
||||
if tc.mainContext != nil && (result.Config.Settings["companion"] != "loaded" || sources[2].Layer != configkit.LayerOverlay) {
|
||||
t.Fatalf("config = %#v, sources = %#v", result.Config, sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
45
facade_main_relative_options_test.go
Normal file
45
facade_main_relative_options_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestMainRelativeOptions(t *testing.T) {
|
||||
if configkit.MainRelative() == configkit.MainRelative() {
|
||||
t.Fatal("binding descriptions must be distinct")
|
||||
}
|
||||
invalid := &configkit.FilesystemContext{BaseDir: "relative", Root: "relative"}
|
||||
cases := []struct {
|
||||
name string
|
||||
main configkit.Input
|
||||
underlay configkit.Input
|
||||
overlay string
|
||||
overlayContext, fallback *configkit.FilesystemContext
|
||||
wantError bool
|
||||
}{
|
||||
{name: "binding on main", main: configkit.Input{Source: configkit.NoSource(), MainRelative: configkit.MainRelative()}, wantError: true},
|
||||
{name: "binding on file", underlay: configkit.Input{Source: configkit.File("missing"), MainRelative: configkit.MainRelative()}, wantError: true},
|
||||
{name: "invalid fallback", underlay: configkit.Input{Source: configkit.NoSource(), MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}, wantError: true},
|
||||
{name: "shadowed binding", underlay: configkit.Input{Source: configkit.NoSource(), Filesystem: &configkit.FilesystemContext{Root: t.TempDir()}, MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}},
|
||||
{name: "inactive overlay fallback", fallback: invalid},
|
||||
{name: "invalid explicit overlay", overlay: "test", overlayContext: invalid, fallback: &configkit.FilesystemContext{Root: t.TempDir(), BaseDir: t.TempDir()}, wantError: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
main := tc.main
|
||||
if tc.name != "binding on main" {
|
||||
main.Source = configkit.NoSource()
|
||||
}
|
||||
opts := configkit.LayeredOptions[layeredFacadeConfig]{Overlay: tc.overlay, OverlayFilesystem: tc.overlayContext, OverlayFallback: tc.fallback}
|
||||
if tc.name == "binding on file" || tc.name == "invalid fallback" || tc.name == "shadowed binding" {
|
||||
opts.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{tc.underlay}}}
|
||||
}
|
||||
_, err := configkit.LoadLayered(main, opts)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("error = %v, want error %v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
260
facade_main_relative_test.go
Normal file
260
facade_main_relative_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestMainRelativeCompanion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 3\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "extra.yml"), []byte("settings: {companion: host}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := configkit.MainRelative()
|
||||
companion := configkit.Input{Source: configkit.Bytes("companions", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: binding}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(dir, "main.yml"))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 3 || result.Config.Settings["companion"] != "host" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
native := configkit.NewFilesystem("bundle", fstest.MapFS{"dir/main.yml": &fstest.MapFile{Data: []byte("count: 4\n")}, "dir/extra.yml": &fstest.MapFile{Data: []byte("settings: {companion: native}\n")}})
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: native.File("dir/main.yml")}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 4 || result.Config.Settings["companion"] != "native" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeContextSelection(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
base := filepath.Join(root, "main")
|
||||
if err := os.Mkdir(base, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, value := range map[string]string{"main/main.yml": "count: 1\n", "main/extra.yml": "settings: {value: derived}\n", "extra.yml": "settings: {value: fallback}\n"} {
|
||||
if err := os.WriteFile(filepath.Join(root, path), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
link := filepath.Join(root, "link.yml")
|
||||
if err := os.Symlink(filepath.Join(base, "main.yml"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
companion := configkit.Input{Source: configkit.Bytes("companion", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}}
|
||||
opts := configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(link), Filesystem: &configkit.FilesystemContext{Root: root}}, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if got := result.Config.Settings["value"]; got != "derived" {
|
||||
t.Fatalf("derived value = %q", got)
|
||||
}
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if got := result.Config.Settings["value"]; got != "fallback" {
|
||||
t.Fatalf("fallback value = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeWithoutBase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, source string
|
||||
requireBase bool
|
||||
wantCode configkit.Code
|
||||
}{
|
||||
{"optional", "config: {imports: [{path: missing.yml}]}\ncount: 7\n", false, ""},
|
||||
{"required", "config: {imports: [{path: missing.yml, required: true}]}\n", false, configkit.CodeImportMissing},
|
||||
{"required directory", "config: {imports: [{dir: missing, required: true}]}\n", false, configkit.CodeImportMissing},
|
||||
{"required glob", "config: {imports: [{glob: '*.yml', required: true}]}\n", false, configkit.CodeImportMissing},
|
||||
{"empty required", "config: {imports: [{path: [], required: true}]}\ncount: 7\n", false, ""},
|
||||
{"base required", "count: 7\n", true, configkit.CodeOptionsInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: true, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||
if test.wantCode != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
for _, d := range err.(*configkit.LoadError).Diagnostics() {
|
||||
if d.Code == test.wantCode {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("diagnostics: %#v", err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 7 {
|
||||
t.Fatalf("config = %#v", result.Config)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeWithoutBaseSequenceItem(t *testing.T) {
|
||||
type item struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
input := configkit.Input{Source: configkit.Bytes("item", []byte("config: {imports: [{path: missing.yml}]}\nname: example\n")), Imports: true, MainRelative: configkit.MainRelative(), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if len(result.Config.Items) != 1 || result.Config.Items[0].Name != "example" {
|
||||
t.Fatalf("config = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeWithoutBaseImportDefaults(t *testing.T) {
|
||||
required := true
|
||||
tests := []struct {
|
||||
name, source string
|
||||
imports bool
|
||||
requireBase bool
|
||||
code configkit.Code
|
||||
}{
|
||||
{"default required", "config: {imports: [{path: missing.yml}]}\n", true, false, configkit.CodeImportMissing},
|
||||
{"malformed", "config: {imports: [{path: 42}]}\n", true, false, configkit.CodeImportInvalid},
|
||||
{"imports disabled", "config: {imports: [{path: missing.yml}]}\n", false, false, configkit.CodeImportDisabled},
|
||||
{"base required without imports", "count: 1\n", false, true, configkit.CodeOptionsInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: test.imports, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}}
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{ImportDefaults: configkit.ImportDefaults{Required: &required}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() {
|
||||
if diagnostic.Code == test.code {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredOverlayContextSelection(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
base := filepath.Join(root, "base")
|
||||
for _, dir := range []string{filepath.Join(root, "overlays"), filepath.Join(base, "overlays")} {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for path, content := range map[string]string{"base/main.yml": "count: 1\n", "base/overlays/test.yml": "count: 2\n", "overlays/test.yml": "count: 3\n"} {
|
||||
if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
main := configkit.Input{Source: configkit.File(filepath.Join(base, "main.yml")), Filesystem: &configkit.FilesystemContext{Root: root}}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 2 {
|
||||
t.Fatalf("derived overlay = %d", result.Config.Count)
|
||||
}
|
||||
result, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: base}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 3 {
|
||||
t.Fatalf("explicit overlay = %d", result.Config.Count)
|
||||
}
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFallback: &configkit.FilesystemContext{Root: root, BaseDir: base}})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 2 {
|
||||
t.Fatalf("fallback overlay = %d", result.Config.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredOverlayNativeContext(t *testing.T) {
|
||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"dir/main.yml": &fstest.MapFile{Data: []byte("count: 1\n")},
|
||||
"dir/overlays/test.yml": &fstest.MapFile{Data: []byte("count: 9\n")},
|
||||
})
|
||||
main := configkit.Input{Source: filesystem.File("dir/main.yml"), Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem}}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
if result.Config.Count != 9 {
|
||||
t.Fatalf("config = %#v", result.Config)
|
||||
}
|
||||
_, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "missing", OverlayFallback: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}})
|
||||
if err == nil {
|
||||
t.Fatal("missing selected overlay succeeded")
|
||||
}
|
||||
for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() {
|
||||
if diagnostic.Code == configkit.CodeOverlayMissing {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||
}
|
||||
|
||||
func TestLayeredOverlayNativeLinks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, mainPath, target string
|
||||
want configkit.Code
|
||||
}{
|
||||
{"outside root", "scope/main.yml", "../../outside.yml", configkit.CodeOverlayOutsideRoot},
|
||||
{"overlay aliases main", "scope/main.yml", "../main.yml", configkit.CodeOverlayRepeated},
|
||||
{"main aliases overlay target", "scope/alias.yml", "../target.yml", configkit.CodeOverlayRepeated},
|
||||
{"contained overlay", "scope/main.yml", "../target.yml", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
native := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"scope/main.yml": {Data: []byte("count: 1\n")},
|
||||
"scope/alias.yml": {Data: []byte("target.yml"), Mode: os.ModeSymlink},
|
||||
"scope/target.yml": {Data: []byte("count: 7\n")},
|
||||
"scope/overlays/test.yml": {Data: []byte(tc.target), Mode: os.ModeSymlink},
|
||||
"outside.yml": {Data: []byte("count: 9\n")},
|
||||
})
|
||||
main := configkit.Input{Source: native.File(tc.mainPath), Filesystem: &configkit.FilesystemContext{Root: "scope", FS: &native}}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||
if tc.want != "" {
|
||||
if err == nil {
|
||||
t.Fatal("overlay link unexpectedly loaded")
|
||||
}
|
||||
diagnostics := err.(*configkit.LoadError).Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != tc.want {
|
||||
t.Fatalf("diagnostics = %#v, want %v", diagnostics, tc.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Count != 7 || result.Sources()[1].Path != "scope/target.yml" {
|
||||
t.Fatalf("config = %#v, sources = %#v", result.Config, result.Sources())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -20,6 +20,8 @@ type tierAcquisitionState struct {
|
||||
|
||||
type inputAcquisitionRequest struct {
|
||||
input loadInput
|
||||
resolved *resolvedFilesystemContext
|
||||
withoutBase bool
|
||||
role sourceVersionRole
|
||||
directiveContext rootDirectiveContext
|
||||
protectionRule *VersionRule
|
||||
@@ -33,6 +35,9 @@ type importAcquisitionRequest struct {
|
||||
}
|
||||
|
||||
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
||||
if request.withoutBase && request.input.mainRelative.RequireBase {
|
||||
return assembledInput{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "main-relative input requires a filesystem base")}
|
||||
}
|
||||
source := request.input.source
|
||||
destination := request.input.destination
|
||||
accounting := state.accounting
|
||||
@@ -101,7 +106,12 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
||||
return input, diagnostics
|
||||
}
|
||||
request.input.source = selectedSource
|
||||
if request.input.imports && selectedSource.kind != sourceNone {
|
||||
if request.input.imports && selectedSource.kind != sourceNone && request.withoutBase {
|
||||
input, diagnostics = acquireContextlessInput(input, request, versionProcessing, policy)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
} else if request.input.imports && selectedSource.kind != sourceNone {
|
||||
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
||||
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -126,6 +136,12 @@ func acquireStandaloneInput(input assembledInput, request inputAcquisitionReques
|
||||
if processing != nil {
|
||||
input.document = processing.payload
|
||||
}
|
||||
if request.resolved != nil {
|
||||
if diagnostic := validateResolvedFilesystemContext(*request.resolved); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.resolvedFilesystem = request.resolved
|
||||
}
|
||||
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
|
||||
72
internal/engine/contextless_imports.go
Normal file
72
internal/engine/contextless_imports.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package engine
|
||||
|
||||
import "go.maximumdirect.net/configkit/internal/document"
|
||||
|
||||
func acquireContextlessInput(input assembledInput, request inputAcquisitionRequest, processing *sourceVersionProcessingResult, policy acquisitionPolicy) (assembledInput, []Diagnostic) {
|
||||
parsed := input.parsed
|
||||
var body *document.Node
|
||||
var declarations []importDeclarationSpec
|
||||
var sequences []sequenceDeclarationSpec
|
||||
var diagnostics []Diagnostic
|
||||
if processing != nil {
|
||||
body, declarations = processing.payload, processing.imports
|
||||
sequences = cloneSequenceDeclarations(processing.sequences)
|
||||
} else {
|
||||
body, declarations, diagnostics = splitImportDirectives(parsed.document)
|
||||
}
|
||||
destination := request.input.destination
|
||||
localDestination := destination
|
||||
if request.input.placementMode == PlacementModeSequenceItem {
|
||||
localDestination = Path{}
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
var policies []resolvedImportPolicy
|
||||
policies, diagnostics = prepareImportDeclarations(declarations, policy.importDefaults, localDestination)
|
||||
if len(diagnostics) == 0 {
|
||||
for i, spec := range declarations {
|
||||
if !policies[i].required || spec.selector == importSelectorPath && len(spec.paths) == 0 {
|
||||
continue
|
||||
}
|
||||
site := spec.fieldSites["path"]
|
||||
if spec.selector == importSelectorPath && len(spec.selectorSites) != 0 {
|
||||
site = spec.selectorSites[0]
|
||||
}
|
||||
if spec.selector == importSelectorDirectory {
|
||||
site = spec.fieldSites["dir"]
|
||||
}
|
||||
if spec.selector == importSelectorGlob {
|
||||
site = spec.fieldSites["glob"]
|
||||
}
|
||||
diagnostics = []Diagnostic{{Code: CodeImportMissing, Severity: SeverityError, Path: site.path.clone(), Message: "import cannot be selected because no filesystem base is available", Primary: cloneLocation(&site.location)}}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
if request.input.placementMode == PlacementModeSequenceItem {
|
||||
diagnostics = prependCompositionDiagnosticPaths(diagnostics, destination.Index(0))
|
||||
}
|
||||
return input, diagnostics
|
||||
}
|
||||
accounting, limits := input.accounting, policy.importLimits
|
||||
if diagnostic := chargeDeclaringImport(parsed, accounting, limits); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
key := importKeyForMainSource(parsed.source)
|
||||
root := acquiredContribution{
|
||||
source: cloneSourceInfo(*parsed.source), payload: body, identity: key,
|
||||
role: importRoleMain, scopeOwner: key, processing: processing, sequenceDeclarations: sequences,
|
||||
}
|
||||
scope, diagnostic := prepareDeclaringImportScope(root, accounting, limits)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
scope, diagnostic = finalizeDeclaringImportScope(scope, root, accounting, limits)
|
||||
if diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
input.document = body
|
||||
input.composition = &composedDocument{acquisition: []acquisitionScope{scope}, importGraph: newImportInclusionGraph(), inputBytes: accounting.bytes, inputNodes: accounting.nodes}
|
||||
input.acquisition = cloneAcquisitionScopes(input.composition.acquisition)
|
||||
return input, nil
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,23 +17,30 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
||||
}
|
||||
return resolvedFilesystemContext{}, diagnostic
|
||||
}
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
if diagnostic := validateResolvedFilesystemContext(resolved); diagnostic != nil {
|
||||
return resolvedFilesystemContext{}, diagnostic
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func validateResolvedFilesystemContext(resolved resolvedFilesystemContext) *Diagnostic {
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
return diagnostic
|
||||
}
|
||||
if resolved.native != nil {
|
||||
return resolved, nil
|
||||
return nil
|
||||
}
|
||||
root, err := os.OpenRoot(resolved.rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
value.Primary = nil
|
||||
return resolvedFilesystemContext{}, &value
|
||||
return &value
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||
return resolvedFilesystemContext{}, &value
|
||||
return &value
|
||||
}
|
||||
return resolved, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,26 +38,30 @@ func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionP
|
||||
source := request.input.input.source
|
||||
context := request.input.input.filesystem
|
||||
main := request.main
|
||||
resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||
resolved := request.input.resolved
|
||||
if resolved == nil {
|
||||
value, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
||||
if diagnostic != nil {
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
resolved = &value
|
||||
}
|
||||
if resolved.native == nil {
|
||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||
if diagnostic := validateResolvedFilesystemDirectories(*resolved); diagnostic != nil {
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
access, err := openResolvedFilesystemAccess(resolved)
|
||||
access, err := openResolvedFilesystemAccess(*resolved)
|
||||
if err != nil {
|
||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
diagnostic.Primary = nil
|
||||
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
|
||||
}
|
||||
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, resolved)
|
||||
composition, sources, diagnostics := acquireImportedWithAccess(request, policy, state, tier, access, *resolved)
|
||||
if len(diagnostics) != 0 {
|
||||
return composition, sources, nil, diagnostics
|
||||
}
|
||||
return composition, sources, &resolved, nil
|
||||
return composition, sources, resolved, nil
|
||||
}
|
||||
|
||||
func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisitionPolicy, loadState *acquisitionState, tier *tierAcquisitionState, access *filesystemAccess, resolved resolvedFilesystemContext) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
@@ -107,9 +111,6 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
||||
_ = closeAccess()
|
||||
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||
}
|
||||
accounting.sources++
|
||||
accounting.bytes += main.bytes
|
||||
accounting.nodes += countInputNodes(main.document)
|
||||
directiveContext := request.input.directiveContext
|
||||
if directiveContext == 0 {
|
||||
directiveContext = rootDirectiveContextImportsOnly
|
||||
@@ -120,7 +121,6 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
||||
request.input.directiveContext = directiveContext
|
||||
state := importTraversal{
|
||||
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
||||
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
||||
sources: []SourceInfo{mainInfo},
|
||||
active: make(map[resourceIdentity]int),
|
||||
importGraph: newImportInclusionGraph(),
|
||||
@@ -129,11 +129,10 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
||||
state.itemScope = true
|
||||
state.itemPrefix = destination.clone()
|
||||
state.itemFallback = destination.clone()
|
||||
state.scope.destination = Path{}
|
||||
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||
}
|
||||
|
||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
||||
if diagnostic := chargeDeclaringImport(main, accounting, limits); diagnostic != nil {
|
||||
_ = closeAccess()
|
||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||
}
|
||||
@@ -159,31 +158,18 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
||||
} else {
|
||||
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||
}
|
||||
root := acquiredContribution{
|
||||
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||
role: importRoleMain, scopeOwner: mainNodeKey, processing: processing,
|
||||
sequenceDeclarations: sequenceDeclarations,
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
if mainIsItem {
|
||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain,
|
||||
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||
})
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}, 0)
|
||||
scope, diagnostic := prepareDeclaringImportScope(root, accounting, limits)
|
||||
if diagnostic != nil {
|
||||
diagnostics = []Diagnostic{*diagnostic}
|
||||
} else {
|
||||
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
placementNodes := destinationWrapperNodeCount(destination)
|
||||
if err != nil {
|
||||
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
||||
} else if accounting.nodes+placementNodes > limits.nodes {
|
||||
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
||||
} else {
|
||||
accounting.nodes += placementNodes
|
||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
||||
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
||||
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
||||
destination: destination.clone(), processing: processing,
|
||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
||||
})
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
|
||||
}
|
||||
state.scope = scope
|
||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, scope.destination, 0)
|
||||
}
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
@@ -200,19 +186,8 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
||||
if closeErr != nil {
|
||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||
}
|
||||
acquisition := state.scope
|
||||
if mainIsItem {
|
||||
localScope := state.scope
|
||||
acquisition = acquisitionScope{
|
||||
owner: mainNodeKey, destination: destination.clone(),
|
||||
contributions: []acquiredContribution{{
|
||||
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
|
||||
processing: processing, sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations), pendingItem: &localScope,
|
||||
}},
|
||||
}
|
||||
}
|
||||
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
|
||||
acquisition, diagnostic := finalizeDeclaringImportScope(state.scope, root, accounting, limits)
|
||||
if diagnostic != nil {
|
||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||
}
|
||||
return &composedDocument{
|
||||
@@ -221,35 +196,27 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
||||
}, cloneSourceInfos(state.sources), nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||
if s.state.accounting.sources > s.policy.importLimits.sources {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||
return &value
|
||||
}
|
||||
if s.state.accounting.bytes > s.policy.importLimits.bytes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||
return &value
|
||||
}
|
||||
if s.state.accounting.nodes > s.policy.importLimits.nodes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
||||
func prepareImportDeclarations(declarations []importDeclarationSpec, defaults ImportDefaults, parentDestination Path) ([]resolvedImportPolicy, []Diagnostic) {
|
||||
policies := make([]resolvedImportPolicy, len(declarations))
|
||||
for index, spec := range declarations {
|
||||
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
||||
policy, diagnostics := resolveImportPolicy(spec, defaults)
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
return nil, diagnostics
|
||||
}
|
||||
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
||||
site := spec.fieldSites["placement"]
|
||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
||||
return nil, []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
||||
}
|
||||
policies[index] = policy
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
||||
policies, diagnostics := prepareImportDeclarations(declarations, s.policy.importDefaults, parentDestination)
|
||||
if len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
for index, spec := range declarations {
|
||||
policy := policies[index]
|
||||
var selection importSelection
|
||||
|
||||
@@ -285,3 +285,58 @@ func importedVersionClaim(documents []*document.Node, scope acquisitionScope, co
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The declaring payload contributes once even when there are no admitted imports.
|
||||
func chargeDeclaringImport(parsed parsedSource, accounting *importAccounting, limits importLimits) *Diagnostic {
|
||||
accounting.sources++
|
||||
accounting.bytes += parsed.bytes
|
||||
accounting.nodes += countInputNodes(parsed.document)
|
||||
if accounting.sources > limits.sources {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||
return &value
|
||||
}
|
||||
if accounting.bytes > limits.bytes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
||||
return &value
|
||||
}
|
||||
if accounting.nodes > limits.nodes {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareDeclaringImportScope(root acquiredContribution, accounting *importAccounting, limits importLimits) (acquisitionScope, *Diagnostic) {
|
||||
scope := acquisitionScope{owner: root.identity}
|
||||
if root.source.placementMode == PlacementModeSequenceItem {
|
||||
root.document = root.payload
|
||||
} else {
|
||||
destination := root.source.Destination
|
||||
_, placed, err := placeDocument(root.payload, destination, root.payload.Location, maxDocumentDepth, maxDocumentNodes)
|
||||
if err != nil {
|
||||
diagnostic := sourceDiagnostic(CodeSourceLimitExceeded, root.source.ID, "configuration exceeds the source depth or node limit after destination placement")
|
||||
return acquisitionScope{}, &diagnostic
|
||||
}
|
||||
placementNodes := destinationWrapperNodeCount(destination)
|
||||
if accounting.nodes+placementNodes > limits.nodes {
|
||||
diagnostic := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
||||
return acquisitionScope{}, &diagnostic
|
||||
}
|
||||
accounting.nodes += placementNodes
|
||||
root.document, root.destination = placed, destination.clone()
|
||||
scope.destination = destination.clone()
|
||||
}
|
||||
scope.contributions = []acquiredContribution{root}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func finalizeDeclaringImportScope(scope acquisitionScope, root acquiredContribution, accounting *importAccounting, limits importLimits) (acquisitionScope, *Diagnostic) {
|
||||
if root.source.placementMode == PlacementModeSequenceItem {
|
||||
root.destination = root.source.Destination.clone()
|
||||
localScope := scope
|
||||
root.pendingItem = &localScope
|
||||
scope = acquisitionScope{owner: root.identity, destination: root.destination.clone(), contributions: []acquiredContribution{root}}
|
||||
}
|
||||
diagnostic := reservePendingItemPlacements(&scope, limits, accounting)
|
||||
return scope, diagnostic
|
||||
}
|
||||
|
||||
@@ -124,7 +124,8 @@ func resolveImportPolicy(spec importDeclarationSpec, defaults ImportDefaults) (r
|
||||
if spec.patternSet {
|
||||
policy.pattern = cloneStrings(spec.pattern)
|
||||
} else {
|
||||
policy.pattern = cloneStrings(defaults.Pattern)
|
||||
// Defaults belong to the load's snapshot. Selectors only read them.
|
||||
policy.pattern = defaults.Pattern
|
||||
}
|
||||
}
|
||||
if spec.depthSet && !policy.recursive {
|
||||
|
||||
@@ -7,10 +7,16 @@ type Input struct {
|
||||
Source Source
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
MainRelative *MainRelativeContext
|
||||
Destination Path
|
||||
PlacementMode PlacementMode
|
||||
}
|
||||
|
||||
type MainRelativeContext struct {
|
||||
Fallback *FilesystemContext
|
||||
RequireBase bool
|
||||
}
|
||||
|
||||
// Underlay contains strictly composed contributions at one precedence tier.
|
||||
type Underlay struct{ Inputs []Input }
|
||||
|
||||
@@ -22,6 +28,7 @@ type LayeredOptions[T any] struct {
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
OverlayFilesystem *FilesystemContext
|
||||
OverlayFallback *FilesystemContext
|
||||
Overrides []Override
|
||||
}
|
||||
|
||||
@@ -37,13 +44,14 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
}
|
||||
request := loadRequest[T]{
|
||||
main: loadInput{
|
||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem, mainRelative: main.MainRelative,
|
||||
destination: main.Destination.clone(), placementMode: main.PlacementMode,
|
||||
},
|
||||
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||
importDefaults: options.ImportDefaults,
|
||||
overlay: options.Overlay,
|
||||
overlayFilesystem: options.OverlayFilesystem,
|
||||
overlayFallback: options.OverlayFallback,
|
||||
overrides: append([]Override(nil), options.Overrides...),
|
||||
underlays: make([]loadGroup, len(options.Underlays)),
|
||||
}
|
||||
@@ -51,7 +59,7 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
||||
request.underlays[index].inputs = make([]loadInput, len(underlay.Inputs))
|
||||
for inputIndex, input := range underlay.Inputs {
|
||||
request.underlays[index].inputs[inputIndex] = loadInput{
|
||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem, mainRelative: input.MainRelative,
|
||||
destination: input.Destination.clone(), placementMode: input.PlacementMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ type loadRequest[T any] struct {
|
||||
effectiveVersion *VersionRule
|
||||
overlay string
|
||||
overlayFilesystem *FilesystemContext
|
||||
overlayFallback *FilesystemContext
|
||||
overrides []Override
|
||||
ordinary bool
|
||||
}
|
||||
@@ -31,6 +32,7 @@ type loadInput struct {
|
||||
source Source
|
||||
imports bool
|
||||
filesystem *FilesystemContext
|
||||
mainRelative *MainRelativeContext
|
||||
destination Path
|
||||
placementMode PlacementMode
|
||||
}
|
||||
@@ -57,6 +59,9 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
||||
request.importDefaults = defaults
|
||||
request.sequencePolicy = policy
|
||||
request.definition.sequenceIdentityRegistrations = nil
|
||||
if request.main.mainRelative != nil && request.main.filesystem == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "main input cannot use main-relative context"))
|
||||
}
|
||||
main, mainDiagnostics := normalizeInput(request.main, "")
|
||||
request.main = main
|
||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||
@@ -64,6 +69,13 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
||||
for groupIndex := range request.underlays {
|
||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
||||
if input.mainRelative != nil && input.filesystem == nil {
|
||||
if input.source.kind == sourceFile || input.source.kind == sourceFilesystem || input.source.kind == sourceSearch {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "file and search underlays cannot use main-relative context"))
|
||||
} else if input.mainRelative.Fallback != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, input.mainRelative.Fallback)...)
|
||||
}
|
||||
}
|
||||
normalized, inputDiagnostics := normalizeInput(*input, "")
|
||||
*input = normalized
|
||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||
@@ -71,10 +83,10 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
||||
}
|
||||
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
||||
if activeOverlay(request.overlay) {
|
||||
if request.overlayFilesystem == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
||||
} else {
|
||||
if request.overlayFilesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFilesystem)...)
|
||||
} else if request.overlayFallback != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFallback)...)
|
||||
}
|
||||
}
|
||||
if len(diagnostics) != 0 {
|
||||
@@ -90,7 +102,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
||||
projectVersion := sequenceVersionPath(request.protectionVersion())
|
||||
for index, group := range request.underlays {
|
||||
evidenceStart := len(input.versionState.evidenceSince(0))
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy)
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index+1, request.protectionVersion(), state, policy, input.resolvedFilesystem)
|
||||
mergeResourceContexts(&input.contexts, acquired.contexts)
|
||||
if len(diagnostics) != 0 {
|
||||
input.sources = append(input.sources, acquired.sources...)
|
||||
@@ -178,10 +190,20 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
||||
assembly.current.inputBytes = state.accounting.bytes
|
||||
assembly.current.inputNodes = state.accounting.nodes
|
||||
if activeOverlay(request.overlay) {
|
||||
if request.overlayFilesystem == nil {
|
||||
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
||||
var resolved resolvedFilesystemContext
|
||||
var diagnostic *Diagnostic
|
||||
useBase := false
|
||||
if request.overlayFilesystem != nil {
|
||||
resolved, diagnostic = resolveAbsentFilesystemContext(request.overlayFilesystem)
|
||||
} else if input.resolvedFilesystem != nil {
|
||||
resolved = *input.resolvedFilesystem
|
||||
useBase = true
|
||||
} else if request.overlayFallback != nil {
|
||||
resolved, diagnostic = resolveAbsentFilesystemContext(request.overlayFallback)
|
||||
useBase = true
|
||||
} else {
|
||||
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires a filesystem context"))
|
||||
}
|
||||
resolved, diagnostic := resolveAbsentFilesystemContext(request.overlayFilesystem)
|
||||
if diagnostic != nil {
|
||||
return input, append(versionDiagnostics, *diagnostic)
|
||||
}
|
||||
@@ -192,6 +214,9 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
||||
name: request.overlay, protectionRule: request.protectionVersion(),
|
||||
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||
}
|
||||
if useBase {
|
||||
overlayRequest.lookupDirectory = resolved.basePath
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||
if diagnostic != nil {
|
||||
@@ -248,12 +273,31 @@ func applyCompletedTier(
|
||||
return operations, nil
|
||||
}
|
||||
|
||||
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||
func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, state *acquisitionState, policy acquisitionPolicy, mainContext *resolvedFilesystemContext) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
|
||||
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
||||
var derived *resolvedFilesystemContext
|
||||
bind := func(input loadInput) (loadInput, *resolvedFilesystemContext) {
|
||||
if input.filesystem != nil || input.mainRelative == nil {
|
||||
return input, nil
|
||||
}
|
||||
if mainContext == nil {
|
||||
input.filesystem = input.mainRelative.Fallback
|
||||
return input, nil
|
||||
}
|
||||
if derived == nil {
|
||||
context := *mainContext
|
||||
context.mainPath = ""
|
||||
context.hostToken = tier.hostToken
|
||||
derived = &context
|
||||
}
|
||||
return input, derived
|
||||
}
|
||||
nativeAnchors := make(map[*namespaceToken]string)
|
||||
for inputIndex, contribution := range group.inputs {
|
||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
||||
contribution, resolved := bind(contribution)
|
||||
token := nativeTokenForLoadInput(contribution, resolved)
|
||||
if token != nil {
|
||||
if _, exists := nativeAnchors[token]; !exists {
|
||||
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
|
||||
}
|
||||
@@ -262,8 +306,11 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
||||
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
||||
inclusionGraph := newImportInclusionGraph()
|
||||
for inputIndex, contribution := range group.inputs {
|
||||
contribution, resolved := bind(contribution)
|
||||
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||
input: contribution, role: sourceVersionContribution,
|
||||
resolved: resolved,
|
||||
withoutBase: contribution.mainRelative != nil && contribution.filesystem == nil && resolved == nil,
|
||||
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
||||
}, policy, state, tier)
|
||||
for _, source := range acquired.sources {
|
||||
@@ -282,7 +329,8 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
||||
kind: "host", host: explicitNamespaceAnchor(index, 0),
|
||||
input: explicitNamespaceAnchor(index, inputIndex+1),
|
||||
}
|
||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
||||
token := nativeTokenForLoadInput(contribution, resolved)
|
||||
if token != nil {
|
||||
anchors.kind = "native"
|
||||
anchors.native = nativeAnchors[token]
|
||||
}
|
||||
@@ -365,7 +413,10 @@ func composeUnderlayGroup(acquired *acquiredUnderlayGroup, rule *VersionRule, po
|
||||
return composition, nil
|
||||
}
|
||||
|
||||
func nativeTokenForLoadInput(input loadInput) *namespaceToken {
|
||||
func nativeTokenForLoadInput(input loadInput, resolved *resolvedFilesystemContext) *namespaceToken {
|
||||
if resolved != nil && resolved.native != nil {
|
||||
return resolved.native.token
|
||||
}
|
||||
if input.source.kind == sourceFilesystem && input.source.namespace != nil {
|
||||
return input.source.namespace.token
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
||||
accounting := &importAccounting{}
|
||||
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
||||
state := &acquisitionState{accounting: accounting}
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, 2, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
||||
t.Fatalf("composed group handoff = %#v", composition)
|
||||
}
|
||||
|
||||
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||
empty, diagnostics := acquireUnderlayGroupScopes(loadGroup{}, 3, nil, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
||||
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
||||
}
|
||||
@@ -471,7 +471,7 @@ func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
|
||||
|
||||
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||
state := &acquisitionState{accounting: accounting}
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||
return acquired, diagnostics
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
44
internal/engine/main_relative_integration_test.go
Normal file
44
internal/engine/main_relative_integration_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestMainRelativeAcquisitionClosesHandlesOnFailure(t *testing.T) {
|
||||
for _, fail := range []string{"parse", "read"} {
|
||||
t.Run(fail, func(t *testing.T) {
|
||||
body := "items: [invalid\n"
|
||||
if fail == "read" {
|
||||
body = "items: {key: value}\n"
|
||||
}
|
||||
filesystem := &observedFilesystem{files: fstest.MapFS{"dir/main.yml": {Data: []byte("{}\n")}, "dir/child.yml": {Data: []byte(body)}}}
|
||||
if fail == "read" {
|
||||
filesystem.failRead = "dir/child.yml"
|
||||
}
|
||||
namespace := newNativeNamespace("bundle", filesystem)
|
||||
main := Input{Source: filesystemFile(namespace, "dir/main.yml")}
|
||||
underlay := Input{Source: Bytes("declarations", []byte("config: {imports: [{path: child.yml}]}\n")), Imports: true, MainRelative: &MainRelativeContext{}}
|
||||
_, err := LoadLayered[importTestConfig](main, SecretBinding{}, LayeredOptions[importTestConfig]{Underlays: []Underlay{{Inputs: []Input{underlay}}}})
|
||||
if err == nil {
|
||||
t.Fatal("invalid selected child succeeded")
|
||||
}
|
||||
filesystem.assertClosed(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainRelativeSharesAcquisitionBudget(t *testing.T) {
|
||||
limits := defaultImportLimits()
|
||||
limits.sources = 1
|
||||
accounting := &importAccounting{}
|
||||
state := &acquisitionState{accounting: accounting}
|
||||
group := loadGroup{inputs: []loadInput{
|
||||
{source: Bytes("one", []byte("items: {one: value}\n")), mainRelative: &MainRelativeContext{}},
|
||||
{source: Bytes("two", []byte("items: {two: value}\n")), imports: true, mainRelative: &MainRelativeContext{}},
|
||||
}}
|
||||
_, diagnostics := acquireUnderlayGroupScopes(group, 1, nil, state, acquisitionPolicy{importLimits: limits}, nil)
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagn
|
||||
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||
}
|
||||
if input.filesystem == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) {
|
||||
if input.filesystem == nil && input.mainRelative == nil && (input.source.kind == sourceBytes || input.source.kind == sourceReader || input.source.kind == sourceMapping) && (input.imports || activeOverlay(ordinaryOverlay)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||
}
|
||||
return input, canonicalDiagnostics(diagnostics)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,11 +29,12 @@ func validOverlayName(name string) bool {
|
||||
}
|
||||
|
||||
type selectedOverlayRequest struct {
|
||||
name string
|
||||
protectionRule *VersionRule
|
||||
sequencePolicy sequencePolicy
|
||||
limits importLimits
|
||||
versionState *sourceVersionState
|
||||
name string
|
||||
lookupDirectory string
|
||||
protectionRule *VersionRule
|
||||
sequencePolicy sequencePolicy
|
||||
limits importLimits
|
||||
versionState *sourceVersionState
|
||||
}
|
||||
|
||||
func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext, main parsedSource, existing *resolvedFilesystemContext) (resolvedFilesystemContext, *Diagnostic) {
|
||||
@@ -70,13 +71,26 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
||||
_ = access.close()
|
||||
}
|
||||
}()
|
||||
overlayPath := access.join(access.rootPath, "overlays/"+request.name+".yml")
|
||||
canonical, err := access.canonical(overlayPath)
|
||||
lookupDirectory := request.lookupDirectory
|
||||
if lookupDirectory == "" {
|
||||
lookupDirectory = access.rootPath
|
||||
}
|
||||
overlayPath := access.join(lookupDirectory, "overlays/"+request.name+".yml")
|
||||
links, _ := access.native.(fs.ReadLinkFS)
|
||||
var canonical string
|
||||
var err error
|
||||
if links != nil {
|
||||
canonical, err = resolveNativeImportPath(access, links, overlayPath)
|
||||
} else {
|
||||
canonical, err = access.canonical(overlayPath)
|
||||
}
|
||||
if err != nil {
|
||||
code := CodeOverlayInvalid
|
||||
message := "selected overlay could not be resolved"
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
code, message = CodeOverlayMissing, "selected overlay does not exist"
|
||||
} else if errors.Is(err, errImportPathOutsideRoot) {
|
||||
code, message = CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root"
|
||||
} else if errors.Is(err, fs.ErrPermission) {
|
||||
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
||||
}
|
||||
@@ -85,12 +99,8 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
||||
if !access.contains(canonical) {
|
||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
||||
}
|
||||
for _, existing := range sources {
|
||||
if existing.Path != "" && existing.Path == canonical &&
|
||||
((access.native == nil && existing.Kind == SourceKindFile) ||
|
||||
(access.native != nil && existing.Kind == SourceKindFilesystem && existing.namespace == access.token)) {
|
||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)}
|
||||
}
|
||||
if overlayRepeatsSource(access, links, canonical, sources) {
|
||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)}
|
||||
}
|
||||
if len(sources)+1 > limits.sources {
|
||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
||||
@@ -169,6 +179,37 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func overlayRepeatsSource(access *filesystemAccess, links fs.ReadLinkFS, canonical string, sources []SourceInfo) bool {
|
||||
// Other inputs can have a wider root. Compare their identities in the
|
||||
// shared native namespace while keeping overlay lookup rooted separately.
|
||||
namespaceAccess := *access
|
||||
namespaceAccess.rootPath = "."
|
||||
for _, source := range sources {
|
||||
if source.Path == "" {
|
||||
continue
|
||||
}
|
||||
if access.native == nil {
|
||||
if source.Kind == SourceKindFile && source.Path == canonical {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if source.Kind != SourceKindFilesystem || source.namespace != access.token {
|
||||
continue
|
||||
}
|
||||
if source.Path == canonical {
|
||||
return true
|
||||
}
|
||||
if links != nil {
|
||||
target, err := resolveNativeImportPath(&namespaceAccess, links, source.Path)
|
||||
if err == nil && target == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readOverlayFile(access *filesystemAccess, name, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||
limit := min(int64(maxSourceBytes), remaining)
|
||||
if limit < 0 {
|
||||
|
||||
26
layered.go
26
layered.go
@@ -19,15 +19,29 @@ type Input struct {
|
||||
PlacementMode PlacementMode
|
||||
Imports bool
|
||||
Filesystem *FilesystemContext
|
||||
// MainRelative binds a non-file underlay to the main input's context when
|
||||
// Filesystem is nil. It does not enable imports.
|
||||
MainRelative *MainRelativeContext
|
||||
}
|
||||
|
||||
// MainRelativeContext selects the main input's resolved context for an underlay.
|
||||
// Fallback is used when the main has no usable context; RequireBase requires one.
|
||||
type MainRelativeContext struct {
|
||||
Fallback *FilesystemContext
|
||||
RequireBase bool
|
||||
}
|
||||
|
||||
// MainRelative returns a fresh default main-relative binding description.
|
||||
func MainRelative() *MainRelativeContext { return &MainRelativeContext{} }
|
||||
|
||||
// Underlay contains inputs that must compose strictly within one tier. Tiers
|
||||
// appear in lowest-to-highest precedence order; an empty tier is a no-op.
|
||||
type Underlay struct{ Inputs []Input }
|
||||
|
||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
|
||||
// ignores it. Overrides remain explicit CLI records, not general mappings.
|
||||
// An active Overlay uses OverlayFilesystem, then the main input's resolved
|
||||
// context, then OverlayFallback. An inactive overlay ignores both contexts.
|
||||
// Overrides remain explicit CLI records, not general mappings.
|
||||
// SourceVersionMode applies one declaration policy to main, underlay, import,
|
||||
// and selected overlay documents. Enabled modes interpret the version path
|
||||
// source-locally, consume accepted non-main declarations, and protect main's
|
||||
@@ -42,7 +56,9 @@ type LayeredOptions[T any] struct {
|
||||
Underlays []Underlay
|
||||
Overlay string
|
||||
OverlayFilesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
// OverlayFallback supplies a context if the main has no usable context.
|
||||
OverlayFallback *FilesystemContext
|
||||
Overrides []Override
|
||||
}
|
||||
|
||||
// LoadLayered acquires the main input first and strictly composes enabled
|
||||
@@ -68,6 +84,9 @@ func toEngineInput(input Input) engine.Input {
|
||||
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
||||
PlacementMode: engine.PlacementMode(input.PlacementMode),
|
||||
}
|
||||
if input.MainRelative != nil {
|
||||
result.MainRelative = &engine.MainRelativeContext{Fallback: toEngineFilesystemContext(input.MainRelative.Fallback), RequireBase: input.MainRelative.RequireBase}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -77,6 +96,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||
Overlay: options.Overlay,
|
||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||
OverlayFallback: toEngineFilesystemContext(options.OverlayFallback),
|
||||
ImportDefaults: toEngineImportDefaults(options.ImportDefaults),
|
||||
}
|
||||
if options.Underlays != nil {
|
||||
|
||||
@@ -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