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
|
descriptions are immutable and reusable; each load resolves dynamic directories
|
||||||
and checks current candidate files again. `Searches` is ordered concatenation.
|
and checks current candidate files again. `Searches` is ordered concatenation.
|
||||||
It preserves the supplied description order and does not deduplicate candidates.
|
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`.
|
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
||||||
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
`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,
|
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
||||||
secret lookup, validation, and publication run once on the resulting values.
|
secret lookup, validation, and publication run once on the resulting values.
|
||||||
|
|
||||||
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
An active layered overlay selects `OverlayFilesystem` first, then the resolved
|
||||||
independent of the main source's namespace. Its name and root-relative lookup
|
main context, then `OverlayFallback`. An invalid selected context fails without
|
||||||
follow the selected-overlay rules below; inactive overlays ignore the context.
|
trying another. An inactive overlay ignores both supplied contexts.
|
||||||
The same path in a different filesystem namespace is not a repeated file.
|
The same path in a different filesystem namespace is not a repeated file.
|
||||||
Ordinary `Load` infers overlay context from sources that provide one; with
|
Ordinary `Load` infers overlay context from sources that provide one; with
|
||||||
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
`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
|
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
|
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||||
history follows precedence order. Default main-version checking happens after
|
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`
|
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
|
||||||
is its slash-relative directory inside that root. This includes imported
|
is its slash-relative directory inside that root. This includes imported
|
||||||
sources, selected overlays, host files even without enabled imports, native
|
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
|
contextless synthetic inputs have no resource context. To open a relative
|
||||||
resource, join `BaseDir` with the application resource name using slash-path
|
resource, join `BaseDir` with the application resource name using slash-path
|
||||||
operations and open that path through `FS`.
|
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
|
whole-record upsert. Incoming contributions use their assigned positions, and
|
||||||
replaced payload remains available as historical evidence.
|
replaced payload remains available as historical evidence.
|
||||||
|
|
||||||
|
During resolution, eligible application-registered records are matched between
|
||||||
|
entry and final snapshots by identity at the same collection destination.
|
||||||
|
Queries at a surviving final position follow that record's entry history, while
|
||||||
|
collection queries retain physical ordering and removed evidence. For example,
|
||||||
|
if `[reports, invoices]` becomes `[invoices]`, item 0 follows invoices' earlier
|
||||||
|
position, the collection retains reports' removal, and the absent old item 1
|
||||||
|
remains available through historical-position queries. An added identity does
|
||||||
|
not inherit the old occupant's history. Source locations stay source-local.
|
||||||
|
|
||||||
A mapping assembled from the selected file and imports has one composite base origin
|
A mapping assembled from the selected file and imports has one composite base origin
|
||||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||||
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||||
@@ -1198,6 +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
|
derived assignments may still record application intent even when there is no
|
||||||
structural change.
|
structural change.
|
||||||
|
|
||||||
|
Resolution compares equal-length ordinary lists recursively by position, so an
|
||||||
|
edit to `/resources/0/path` can be recorded at that field. A length change needs
|
||||||
|
a list or ancestor record. For application-registered collections, valid unique
|
||||||
|
entry and final identities match records at the same destination; changing order
|
||||||
|
or membership still requires list or ancestor coverage. Invalid or duplicate
|
||||||
|
endpoint identities make a changed list atomic for auditing, without an early
|
||||||
|
identity error; final registration validation remains at its normal boundary.
|
||||||
|
YAML-only declarations do not provide this correspondence. Records and helper
|
||||||
|
targets use final positions, and helpers never follow a later move automatically.
|
||||||
|
Fixed entry queries continue to use entry positions. See
|
||||||
|
[ADR 0021](adr/0021-sequence-resolution-correspondence.md) for the decision.
|
||||||
|
|
||||||
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
|
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
|
||||||
locations. Reported errors fail the load; warnings survive on a successful result. A
|
locations. Reported errors fail the load; warnings survive on a successful result. A
|
||||||
returned resolver error adds one generic `resolve.hook_failed` error only when the
|
returned resolver error adds one generic `resolve.hook_failed` error only when the
|
||||||
@@ -1356,7 +1399,7 @@ Incompatible overlaps and invalid registrations produce `options.invalid` before
|
|||||||
source acquisition. Unused and root registrations are allowed and have no effect.
|
source acquisition. Unused and root registrations are allowed and have no effect.
|
||||||
|
|
||||||
Registrations provide identity for strict composition, sequence operations,
|
Registrations provide identity for strict composition, sequence operations,
|
||||||
inheritance lookup, and final validation. Dynamic selectors apply at concrete
|
inheritance lookup, resolution correspondence, and final validation. Dynamic selectors apply at concrete
|
||||||
strict-composition, inheritance, and final-result destinations without requiring
|
strict-composition, inheritance, and final-result destinations without requiring
|
||||||
the application to enumerate mapping keys. Each strict tier has independent
|
the application to enumerate mapping keys. Each strict tier has independent
|
||||||
duplicate-identity state among its peers; layered precedence and inheritance
|
duplicate-identity state among its peers; layered precedence and inheritance
|
||||||
@@ -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
|
`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
|
`[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.
|
`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,
|
no extension fallback, case normalization, inheritance between overlay files,
|
||||||
or overlay stack. A selected missing overlay is an error.
|
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
|
With ordinary `Load` and `NoSource`, the main input stays absent and the explicit
|
||||||
supplies the overlay root. The overlay is recorded as the first acquired source
|
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
|
when no other source exists. Host contexts may use the root-only form described
|
||||||
under [single-source loading](#single-source-loading); native contexts retain
|
under [single-source loading](#single-source-loading); native contexts retain
|
||||||
their complete namespace, base, and root requirements.
|
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
|
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
|
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.
|
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 destinations follow the
|
||||||
[collection operation and presence rules](#strict-document-composition) above.
|
[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
|
Each selected document follows the same strict combination rules. A missing
|
||||||
optional input can be absent; invalid declarations, unreadable selected files,
|
optional input can be absent; invalid declarations, unreadable selected files,
|
||||||
and conflicting settings still fail loading.
|
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` |
|
| 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.
|
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
|
||||||
|
|
||||||
Private structural comparison operates on canonical trees. Mapping additions,
|
Private structural comparison operates on canonical trees. Mapping additions,
|
||||||
removals, and descendant replacements retain semantic paths. A sequence is atomic:
|
removals, and descendant replacements retain semantic paths. The shared
|
||||||
any sequence change reports the sequence path and never invents correspondence
|
comparison used by effective diff treats sequences atomically. Resolution uses
|
||||||
between old and new indices. Cross-kind and null/container changes replace the
|
its separate positional and registered-identity comparison described above.
|
||||||
affected path.
|
Cross-kind and null/container changes replace the affected path.
|
||||||
|
|
||||||
These mechanisms support decoding, resolution auditing, metadata,
|
These mechanisms support decoding, resolution auditing, metadata,
|
||||||
and rendering. They are not a public generic conversion or cloning API.
|
and rendering. They are not a public generic conversion or cloning API.
|
||||||
|
|||||||
@@ -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
|
- Use `LoadLayered` when several inputs should supply lower-priority defaults
|
||||||
beneath the main source. Each underlay group combines its inputs strictly.
|
beneath the main source. Each underlay group combines its inputs strictly.
|
||||||
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
|
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).
|
[`LayeredOptions`](../../layered.go).
|
||||||
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||||
[`LayeredOptions`](../../layered.go): the application definition (including
|
[`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
|
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||||
discovery composed with sequences, see the
|
discovery composed with sequences, see the
|
||||||
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
[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
|
## 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
|
choose another explicit base. The example intentionally performs neither target
|
||||||
existence checks nor confinement checks.
|
existence checks nor confinement checks.
|
||||||
|
|
||||||
|
### Normalizing paths inside a list
|
||||||
|
|
||||||
|
Suppose `/srv/catalog/resources.yml` supplies this configuration, and the
|
||||||
|
application interprets file-authored relative paths against their declaring
|
||||||
|
file's directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
- name: reports
|
||||||
|
path: ./reports
|
||||||
|
- name: invoices
|
||||||
|
path: ./invoices
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same source-aware policy as `LoadNotariusPaths` to each `path` field
|
||||||
|
while the list remains in its entry order:
|
||||||
|
|
||||||
|
1. Build the field's semantic path, such as
|
||||||
|
`configkit.Keys("resources").Index(0).Key("path")`.
|
||||||
|
2. Query `Resolution.EntryInfo` at that path and use `Resolution.Source` to
|
||||||
|
identify its declaring file. Choose the base under the application's policy;
|
||||||
|
defaults, CLI values, and missing or composite provenance need their own
|
||||||
|
handling, as in the maintained example above.
|
||||||
|
3. Resolve the relative value against that base and use `Normalize` to assign
|
||||||
|
and record the individual field. For a mutation already performed by the
|
||||||
|
application, use `RecordNormalization` instead.
|
||||||
|
4. Continue without reordering the list. Later resource access uses the resolved
|
||||||
|
value rather than trying to recover a base directory from its effective origin.
|
||||||
|
|
||||||
|
For these file-authored values, the resulting configuration is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
- name: reports
|
||||||
|
path: /srv/catalog/reports
|
||||||
|
- name: invoices
|
||||||
|
path: /srv/catalog/invoices
|
||||||
|
```
|
||||||
|
|
||||||
|
Each changed `path` has a resolution origin, while its history retains the
|
||||||
|
earlier relative value and source evidence. Unchanged fields such as `name`
|
||||||
|
retain their source origins. Field-level recording avoids attributing the whole
|
||||||
|
list to the path-normalization operation; it does not preserve a file origin on
|
||||||
|
the normalized fields themselves.
|
||||||
|
|
||||||
|
If the application also restructures the list during resolution, a whole-list
|
||||||
|
record covers the structural change. For a collection registered with `name`
|
||||||
|
as its identity, history follows each surviving record through a reorder, but
|
||||||
|
descendants can acquire synthetic resolution origins without source locations.
|
||||||
|
Historical source evidence and effective origins answer different questions;
|
||||||
|
identity-aware history does not automatically select a resource base for a
|
||||||
|
derived value. Resolve source-relative values before moving records, and account
|
||||||
|
for final positions when recording helper targets. `EntryInfo` continues to use
|
||||||
|
entry positions even after a move.
|
||||||
|
|
||||||
|
See [resolution recording](../config.md#resolution-recording-and-failure-rules)
|
||||||
|
for helper and coverage behavior, and
|
||||||
|
[origins and historical paths](../config.md#presence-origins-and-historical-paths)
|
||||||
|
for the distinction between resolution origins and retained source evidence.
|
||||||
|
|
||||||
## Intentional migration changes
|
## Intentional migration changes
|
||||||
|
|
||||||
When upgrading, start with the changes relevant to your dependency and inputs:
|
When upgrading, start with the changes relevant to your dependency and inputs:
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ Accepted decisions:
|
|||||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||||
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
||||||
|
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
|
||||||
|
- [Bind companion contexts after main selection](adr/0022-main-relative-context.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -92,6 +94,25 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
|||||||
|
|
||||||
## Baseline validation
|
## 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,
|
The completed filesystem-source and underlay roadmap, original feature request,
|
||||||
implementation plan, and resolved remediation record are preserved in Git history
|
implementation plan, and resolved remediation record are preserved in Git history
|
||||||
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
||||||
@@ -250,15 +271,19 @@ dependency downloads. Acquire dependencies and any selected toolchain beforehand
|
|||||||
Format reported Go files with `gofmt -w <files>`.
|
Format reported Go files with `gofmt -w <files>`.
|
||||||
|
|
||||||
The default suite includes executable examples, maintained consumer fixtures,
|
The default suite includes executable examples, maintained consumer fixtures,
|
||||||
local documentation-link checks, and the dependency import-boundary check.
|
and the dependency/public boundary checks.
|
||||||
It must stay deterministic and runnable without network access or credentials.
|
It must stay deterministic and runnable without network access or credentials.
|
||||||
Still inspect new/untracked content and review paths, examples, ownership, and
|
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||||
current-versus-planned claims; automated checks do not replace content review.
|
current-versus-planned claims; automated checks do not replace content review.
|
||||||
|
|
||||||
Use focused checks while iterating, with validation proportionate to the change
|
Use focused checks while iterating, with validation proportionate to the change
|
||||||
under the testing policy. Documentation-only work normally needs the local-link
|
under the testing policy. Documentation-only work normally needs review of
|
||||||
check below and whitespace review; behavior-bearing examples and commands also
|
affected links and whitespace; behavior-bearing examples and commands also
|
||||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
need execution. Automated Markdown link checking runs against the committed
|
||||||
|
candidate through ReleaseKit, not during ordinary Go tests or the everyday
|
||||||
|
checker. See the [release procedure](release.md#4-publish-the-committed-candidate)
|
||||||
|
for the selected checks and optional diagnostic command. Use
|
||||||
|
`go test -race ./...` for ownership or concurrency changes.
|
||||||
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
||||||
its selected validation through one `publish` invocation. The everyday checker
|
its selected validation through one `publish` invocation. The everyday checker
|
||||||
remains a development gate and is not a separate release prerequisite. CI never
|
remains a development gate and is not a separate release prerequisite. CI never
|
||||||
@@ -285,6 +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` |
|
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||||
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||||
|
| Resolution sequence matching and inspection | [Correspondence](../internal/engine/resolution_correspondence_test.go), [auditing and history](../internal/engine/resolution_compare_test.go), [public integration](../facade_resolution_sequences_test.go) | `go test ./internal/engine -run 'Resolution\|History\|Explain\|Sensitivity'` and `go test . -run 'Resolution\|SequenceIdentity\|Diff'` |
|
||||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||||
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||||
@@ -293,7 +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` |
|
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||||
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
|
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
|
||||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
| Dependency and public API isolation | [Import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
|
|
||||||
## Fuzz validation
|
## Fuzz validation
|
||||||
|
|||||||
@@ -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
|
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||||
candidate normalization. They do not suppress discovery or resource errors.
|
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.
|
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
|
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
|
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
|
explicitly selected configuration discovery. A search resolves once during
|
||||||
shared acquisition; the selected concrete file or absent-input state then
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
follows the existing composition, metadata, and resource-context lifecycle.
|
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
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
and operations for identified sequences, and application identity registrations for strict
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
composition, inheritance, and final validation. It applies the selected
|
composition, inheritance, and final validation. It applies the selected
|
||||||
@@ -50,7 +50,7 @@ Implemented component owners:
|
|||||||
- Engine mechanism tests own source, composition, inheritance, decoding,
|
- Engine mechanism tests own source, composition, inheritance, decoding,
|
||||||
callback, provenance,
|
callback, provenance,
|
||||||
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
||||||
own public API compatibility, facade integration, documentation links, and
|
own public API compatibility, facade integration, and
|
||||||
dependency/public boundary checks. Consumer examples remain under
|
dependency/public boundary checks. Consumer examples remain under
|
||||||
`examples/consumers`.
|
`examples/consumers`.
|
||||||
|
|
||||||
@@ -161,6 +161,9 @@ before application resolution begins. The callback retains its separate fixed
|
|||||||
entry view; it does not read a builder that continues to change. After successful
|
entry view; it does not read a builder that continues to change. After successful
|
||||||
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
||||||
adds the original resolution records and their net replay groups. At the existing
|
adds the original resolution records and their net replay groups. At the existing
|
||||||
|
resolution boundary, [`resolution_correspondence.go`](../../internal/engine/resolution_correspondence.go)
|
||||||
|
matches eligible registered records and the shared history retains their paired
|
||||||
|
coordinates for selection and safe replay. At the existing
|
||||||
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
|
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
|
||||||
freezes that builder and counts the retained document roots before secret lookup
|
freezes that builder and counts the retained document roots before secret lookup
|
||||||
and validation.
|
and validation.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Standard Repository Policies
|
## Standard Repository Policies
|
||||||
|
|
||||||
Material: `docs/policy/release.md`, adapted from
|
Material: `docs/policy/documentation.md`, `docs/policy/oss.md`, and
|
||||||
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
`docs/policy/release.md`, adapted from
|
||||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
`b339ad0427a50e43a68b932a6a9f4669cc0f6252`; `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||||
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||||
Policy Reading Guide in `docs/development.md`, adapted from
|
Policy Reading Guide in `docs/development.md`, adapted from
|
||||||
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
`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) |
|
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) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
[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/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ initialization, and resource checks. See the [configuration discovery
|
|||||||
contract](../config.md#configuration-discovery) and [ADR
|
contract](../config.md#configuration-discovery) and [ADR
|
||||||
0019](../adr/0019-explicit-configuration-discovery.md).
|
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
|
The engine applies each input's mapping destination within the existing source
|
||||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||||
retains each effective placement with source identity and inspection records;
|
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
|
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||||
assignments, or application resolution.
|
assignments, or application resolution.
|
||||||
|
|
||||||
|
For resolution auditing and inspection, eligible registered collections match
|
||||||
|
entry and final records by identity within each destination. Structural changes
|
||||||
|
still require container coverage, and this correspondence does not change source
|
||||||
|
composition or move final validation earlier. Public diff remains sequence-atomic.
|
||||||
|
|
||||||
Preserve source attribution, presence, history, sensitivity, and resource context
|
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||||
available origin information without exposing identity values. The guarantee
|
available origin information without exposing identity values. The guarantee
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Documentation Policy
|
# 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
|
## 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
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.
|
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
|
Rejected architectural alternatives and significant architectural exclusions
|
||||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
ideas.
|
||||||
represent a significant architectural decision.
|
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Open Source Licensing And Attribution Policy
|
# 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
|
## 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)
|
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)
|
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.
|
to create the project’s root `COPYRIGHT` file. Use the examples as provided,
|
||||||
Use verified project names, contribution years, and copyright holders.
|
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
|
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
||||||
text references together to reflect the author's selected grant.
|
text references together to reflect the author's selected grant.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Release Policy
|
# 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
|
## Purpose
|
||||||
|
|
||||||
@@ -110,6 +110,10 @@ For releases at or after `v1.0.0`:
|
|||||||
minimum as described below; and
|
minimum as described below; and
|
||||||
- a patch release may make backward-compatible fixes.
|
- 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,
|
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||||
configuration, data, integration, or compatibility contract when the release
|
configuration, data, integration, or compatibility contract when the release
|
||||||
note explains the impact and required consumer action. A patch release must
|
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
|
The selected profile runs the complete deterministic, offline default suite
|
||||||
once with the race detector and test-time vet disabled. The suite already
|
once with the race detector and test-time vet disabled. The suite already
|
||||||
includes examples, consumer fixtures, documentation links, and package
|
includes examples, consumer fixtures, and package boundaries. One full vet
|
||||||
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
pass adds static checks beyond the test-time subset.
|
||||||
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||||
configurations distinct from native race compilation. macOS and newer-toolchain
|
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||||
checks are optional.
|
checks are optional.
|
||||||
@@ -231,6 +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
|
the check does not inspect file contents or Git history. Hosted credential
|
||||||
preflight belongs to delivery and adds no source-publication gate.
|
preflight belongs to delivery and adds no source-publication gate.
|
||||||
|
|
||||||
|
ConfigKit delegates committed Markdown link validation to ReleaseKit rather
|
||||||
|
than maintaining a documentation-link test in the Go suite. Links outside the
|
||||||
|
checkout remain out of scope. This moves automated link feedback to candidate
|
||||||
|
validation; ordinary development review still covers edited links.
|
||||||
|
|
||||||
|
ConfigKit also selects ReleaseKit's local-replacement check for committed module
|
||||||
|
declarations. Filesystem replacements can make local validation succeed with
|
||||||
|
dependencies unavailable to consumers of the published module. Versioned module
|
||||||
|
replacements remain permitted. This check does not validate remote dependency
|
||||||
|
availability or require consumer retrieval.
|
||||||
|
|
||||||
ReleaseKit runs its selected Go commands with readonly module mode and
|
ReleaseKit runs its selected Go commands with readonly module mode and
|
||||||
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||||
validation. Formatting and whitespace remain ordinary development checks in
|
validation. Formatting and whitespace remain ordinary development checks in
|
||||||
|
|||||||
150
docs/release.md
150
docs/release.md
@@ -31,7 +31,8 @@ version supported for applications using ConfigKit.
|
|||||||
|
|
||||||
Release validation runs the offline Go test suite once with the race detector,
|
Release validation runs the offline Go test suite once with the race detector,
|
||||||
then a full vet pass and package compilation for both Linux targets with cgo
|
then a full vet pass and package compilation for both Linux targets with cgo
|
||||||
disabled (`CGO_ENABLED=0`). macOS and CI are
|
disabled (`CGO_ENABLED=0`), followed by the selected committed-file checks
|
||||||
|
described in [publication](#4-publish-the-committed-candidate). macOS and CI are
|
||||||
not release gates; do not wait for or poll CI.
|
not release gates; do not wait for or poll CI.
|
||||||
|
|
||||||
The vanity module path is the name applications put in Go imports. Source/tag
|
The vanity module path is the name applications put in Go imports. Source/tag
|
||||||
@@ -93,22 +94,18 @@ Prepare dependencies whether or not the minimum changes:
|
|||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
|
Run the module-selected ReleaseKit tool from the repository root. Go builds
|
||||||
the checkout.
|
and caches the executable; no temporary executable or launcher is needed.
|
||||||
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
These commands use the version recorded in `go.mod`:
|
||||||
commands never install another ReleaseKit version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
|
|
||||||
releasekit_bin="$releasekit_tmp/releasekit"
|
|
||||||
go build -o "$releasekit_bin" go.maximumdirect.net/releasekit/cmd/releasekit
|
|
||||||
go list -m go.maximumdirect.net/releasekit
|
go list -m go.maximumdirect.net/releasekit
|
||||||
"$releasekit_bin" version
|
go tool releasekit version
|
||||||
```
|
```
|
||||||
|
|
||||||
This repository selects ReleaseKit v0.6.0. Use the compiled program above for
|
This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the
|
||||||
publication so its exit status and publication report reach you unchanged.
|
tool's exit status and publication report; `go run` maps a nonzero child exit
|
||||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
status to 1. ReleaseKit's child Go commands
|
||||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||||
executable first on `PATH` through the publication command.
|
executable first on `PATH` through the publication command.
|
||||||
|
|
||||||
@@ -174,10 +171,10 @@ candidate to `main`, and invoke validation again.
|
|||||||
## 4. Publish the committed candidate
|
## 4. Publish the committed candidate
|
||||||
|
|
||||||
Once publication is authorized and the candidate is on the publication remote's
|
Once publication is authorized and the candidate is on the publication remote's
|
||||||
`main`, run the compiled ReleaseKit program once to validate and publish it:
|
`main`, run the module-selected ReleaseKit tool once to validate and publish it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
go tool releasekit publish vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the placeholder with the selected version. ReleaseKit checks the
|
Replace the placeholder with the selected version. ReleaseKit checks the
|
||||||
@@ -186,14 +183,20 @@ It verifies the candidate on
|
|||||||
`main`, and checks the release-note H1 for the product and version. It then
|
`main`, and checks the release-note H1 for the product and version. It then
|
||||||
runs the selected race-enabled standard suite once with test-time vet disabled,
|
runs the selected race-enabled standard suite once with test-time vet disabled,
|
||||||
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
||||||
arm64. It publishes only the annotated tag for that candidate. Confirmed
|
arm64. It also checks the committed candidate for the forbidden token path,
|
||||||
|
filesystem replacements in module declarations, and local Markdown destinations
|
||||||
|
and supported heading fragments. Markdown links outside the checkout are
|
||||||
|
reported as out of scope without failing validation. These file checks inspect
|
||||||
|
the committed candidate, not uncommitted or ignored working-tree content.
|
||||||
|
|
||||||
|
It publishes only the annotated tag for that candidate. Confirmed
|
||||||
publication of the intended tag completes the release. The tag points to the
|
publication of the intended tag completes the release. The tag points to the
|
||||||
captured commit and uses the complete checked-in note as its annotation message.
|
captured commit and uses the complete checked-in note as its annotation message.
|
||||||
Use the optional `check` command for diagnosis when useful. It validates without
|
Use the optional `check` command for diagnosis when useful. It validates without
|
||||||
publishing and is not required before `publish`, which performs its own checks:
|
publishing and is not required before `publish`, which performs its own checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
go tool releasekit check vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
For `publish`, interpret the program's exit status as follows:
|
For `publish`, interpret the program's exit status as follows:
|
||||||
@@ -215,11 +218,11 @@ remote read-back or consumer retrieval; those checks remain optional.
|
|||||||
## 5. Deliver the hosted release page
|
## 5. Deliver the hosted release page
|
||||||
|
|
||||||
After source publication succeeds, run local hosted delivery from the repository
|
After source publication succeeds, run local hosted delivery from the repository
|
||||||
root. Keep the worktree clean and `HEAD` at the published tag's commit. Reuse the
|
root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the
|
||||||
compiled executable from step 1. For the first release using this workflow:
|
module-selected tool as in step 1. For the first release using this workflow:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" deliver v1.0.0
|
go tool releasekit deliver v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
For subsequent releases, substitute the selected version. The profile selects
|
For subsequent releases, substitute the selected version. The profile selects
|
||||||
@@ -247,8 +250,8 @@ separately.
|
|||||||
A delivery problem leaves the successfully published source tag intact. Retry
|
A delivery problem leaves the successfully published source tag intact. Retry
|
||||||
delivery with the same immutable version after correcting the cause and
|
delivery with the same immutable version after correcting the cause and
|
||||||
resolving any uncertain write. For a later retry, use a clean checkout of the
|
resolving any uncertain write. For a later retry, use a clean checkout of the
|
||||||
original tag, fetching that tag if needed. Build the module-selected CLI from
|
original tag, fetching that tag if needed. Use that checkout's module-selected
|
||||||
that checkout using step 1's tool-build commands, without changing the tagged
|
CLI, prepare its dependencies and tool as in step 1 without changing the tagged
|
||||||
module files, and make the token available there. For example, with the tag
|
module files, and make the token available there. For example, with the tag
|
||||||
available locally:
|
available locally:
|
||||||
|
|
||||||
@@ -260,7 +263,7 @@ git switch --detach "$release_version"
|
|||||||
After preparing the tool in that tagged checkout:
|
After preparing the tool in that tagged checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" deliver "$release_version"
|
go tool releasekit deliver "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
|
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
|
||||||
@@ -269,53 +272,80 @@ version through the normal release procedure.
|
|||||||
|
|
||||||
## 6. Recover from a failed or uncertain tag push
|
## 6. Recover from a failed or uncertain tag push
|
||||||
|
|
||||||
The executable reports the publication state, phase, version, candidate commit,
|
ReleaseKit attempts to save publication evidence before pushing a tag and
|
||||||
tag-object ID, effective endpoint, and error on standard error. After a failed
|
reports the absolute record path, or a recording warning, separately from the
|
||||||
tag push, it makes one bounded check of the exact publication remote to find out
|
publication outcome. A recording failure does not undo a successful publication
|
||||||
whether the tag arrived. Exit status 3
|
or change its exit status. Confirmed publication still completes the source
|
||||||
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
release; hosted delivery remains a separate operation.
|
||||||
|
|
||||||
An annotated tag is a Git object containing the release note and a pointer to
|
Records default to a private store under the repository's Git common directory,
|
||||||
the source commit. Its tag-object ID identifies that complete object. Checking
|
shared by linked worktrees. `publish --record-dir DIR` can select another
|
||||||
only the commit ID would not establish that the intended tag object arrived.
|
location; relative paths resolve from the source repository root. Records can
|
||||||
|
contain exact endpoints, including credentials. Keep them private and remove
|
||||||
|
them when their recovery value expires. There is no new tracked storage,
|
||||||
|
credential-preflight gate, or required recording-success check.
|
||||||
|
|
||||||
For an uncertain result, use the exact endpoint (remote URL), candidate commit,
|
After a failed push, ReleaseKit makes one bounded observation of the original
|
||||||
version, and tag-object ID from the report. Copy the captured endpoint into the variable
|
publication endpoint. If the outcome remains unknown, use the explicitly saved
|
||||||
below using normal shell quoting. Check that Git still resolves it to itself,
|
record rather than rerunning publication. Copy the recovery command printed by
|
||||||
then query the authoritative refs once:
|
ReleaseKit, or use the module-selected tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
publication_record='/absolute/path/from/the/publication/report.json'
|
||||||
|
go tool releasekit reconcile --record "$publication_record"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the path was lost, discover local attempts without contacting a remote:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go tool releasekit publication-attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
Select the record for the intended attempt from its reported version, candidate,
|
||||||
|
and tag evidence; discovery does not automatically choose the latest attempt.
|
||||||
|
For an alternate store, supply `publication-attempts --record-dir DIR`.
|
||||||
|
Both commands support optional `--json` output. Treat saved records as private
|
||||||
|
regardless of the CLI's credential-redacted diagnostic output.
|
||||||
|
|
||||||
|
Reconciliation observes the captured endpoint and exact tag object using current
|
||||||
|
supported Git transport settings. It does not load the current profile, require
|
||||||
|
the original `HEAD`, rerun candidate validation, change tags, or contact Gitea
|
||||||
|
hosted delivery. It needs no clean candidate checkout. Use ReleaseKit v0.8.0 or
|
||||||
|
another CLI supporting the saved record format, including format 2 where exact
|
||||||
|
non-UTF-8 endpoint/path bytes were retained.
|
||||||
|
|
||||||
|
| Reconciliation status | Meaning and next action |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | The intended tag object is published. The source release is complete; continue with hosted delivery from its clean tagged checkout. |
|
||||||
|
| `1` | The tag is currently absent or conflicting. Read the reported state. Investigate conflicts; absence alone does not prove that the version was never published. |
|
||||||
|
| `2` | The record cannot be read/decoded, or the invocation is invalid. Correct the path, access, CLI compatibility, or command. |
|
||||||
|
| `3` | Observation remains unresolved, including insufficient evidence or cancellation. Correct the transport or other reported cause before another explicit observation; do not blindly republish. |
|
||||||
|
|
||||||
|
These are recovery actions after failure, not read-back requirements after a
|
||||||
|
successful push. A failed observation leaves the result unresolved. Do not poll
|
||||||
|
CI or move a tag to settle uncertainty.
|
||||||
|
|
||||||
|
When no usable record exists (for example, storage failed or an older CLI made
|
||||||
|
the attempt), use the captured endpoint and exact tag-object ID from the original
|
||||||
|
report for a manual authoritative observation. Checking only the peeled commit
|
||||||
|
would not establish that the intended annotated tag object arrived. Do not
|
||||||
|
substitute current profile or local tag state for missing original evidence.
|
||||||
|
|
||||||
|
Only after establishing that a retained local tag was never published or used
|
||||||
|
by another release channel may it be removed before retrying publication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
authoritative='COPY_CAPTURED_ENDPOINT'
|
|
||||||
release_version='vMAJOR.MINOR.PATCH'
|
release_version='vMAJOR.MINOR.PATCH'
|
||||||
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
|
|
||||||
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
|
|
||||||
```
|
|
||||||
|
|
||||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
|
||||||
Correct transport configuration before continuing; a rewritten destination
|
|
||||||
cannot establish absence at the captured endpoint.
|
|
||||||
|
|
||||||
Compare the `main` object ID with the reported candidate commit and the tag
|
|
||||||
object ID with the reported tag-object ID. `--refs` returns the annotated tag
|
|
||||||
object itself. A peeled commit is the source commit reached by following that
|
|
||||||
tag; it is a different identity. If the tag object matches, the
|
|
||||||
release is published. If the query fails, the result is conflicting, or the
|
|
||||||
endpoint or candidate differs, stop and resolve the state before retrying. If
|
|
||||||
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
|
||||||
the reported candidate, remove a retained local tag only after confirming it
|
|
||||||
was not published or used by another channel; then rerun `publish`, which
|
|
||||||
revalidates the candidate:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag -d "$release_version"
|
git tag -d "$release_version"
|
||||||
"$releasekit_bin" publish "$release_version"
|
go tool releasekit publish "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Replace the placeholder with the original attempt's version.
|
||||||
|
The next `publish` revalidates the candidate and publication guards. A conflicting
|
||||||
|
published tag must never be moved or reused. A defect in published source
|
||||||
|
requires a corrected commit, a new appropriate version, and a matching note.
|
||||||
A validation failure after the candidate reached `main` leaves an untagged
|
A validation failure after the candidate reached `main` leaves an untagged
|
||||||
candidate there. Correct it with a new commit rather than rewriting or rolling
|
candidate there; correct it in a new commit rather than rewriting `main`.
|
||||||
back `main`. A conflicting published tag must never be moved or reused. A
|
|
||||||
defect in published source requires a corrected commit, a new semantically
|
|
||||||
appropriate version, and a matching release note.
|
|
||||||
|
|
||||||
## 7. Optional consumer verification
|
## 7. Optional consumer verification
|
||||||
|
|
||||||
|
|||||||
79
docs/releases/v1.1.0.md
Normal file
79
docs/releases/v1.1.0.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# ConfigKit v1.1.0
|
||||||
|
|
||||||
|
ConfigKit now accepts field-level resolution records inside lists and follows
|
||||||
|
registered records across position changes when reporting their provenance and
|
||||||
|
history. This release also standardizes more release validation on ReleaseKit.
|
||||||
|
|
||||||
|
## Field-level resolution in lists
|
||||||
|
|
||||||
|
Applications can normalize or derive a field inside a list and record that
|
||||||
|
field directly. For example, changing `/resources/0/path` can be covered by
|
||||||
|
`RecordNormalization` at that path, without recording the entire `/resources`
|
||||||
|
list when no other changes require coverage.
|
||||||
|
|
||||||
|
For ordinary lists of equal length, resolution auditing compares corresponding
|
||||||
|
positions recursively. Positions are addresses; ConfigKit does not infer an
|
||||||
|
element's identity from similar values. A length change still requires a record
|
||||||
|
for the list or an ancestor.
|
||||||
|
|
||||||
|
For collections registered through `Definition.SequenceIdentities`, ConfigKit
|
||||||
|
matches records between the resolution-entry and final snapshots when both
|
||||||
|
endpoints have valid, unique identities. Field edits can be recorded individually.
|
||||||
|
Changes to identity order or membership still require list or ancestor coverage;
|
||||||
|
changing an identity is treated as removal and addition. When matching is
|
||||||
|
unavailable, a changed registered collection is audited as a whole. Existing
|
||||||
|
final identity validation remains in place, allowing resolution to repair entry
|
||||||
|
values.
|
||||||
|
|
||||||
|
See [recorded application resolution](../config.md#recorded-application-resolution)
|
||||||
|
for the complete rules.
|
||||||
|
|
||||||
|
## Matched history and inspection
|
||||||
|
|
||||||
|
For eligible registered collections, inspection at a surviving record's final
|
||||||
|
position follows its own entry evidence, including earlier values, input
|
||||||
|
explicitness, and source attribution. A record no longer inherits the evidence
|
||||||
|
of a different record that previously occupied its final position. Original
|
||||||
|
source coordinates remain intact, while reported operation paths follow the
|
||||||
|
matched final record.
|
||||||
|
|
||||||
|
Collection inspection retains removed records' evidence. Removed fields of
|
||||||
|
surviving moved records remain available through metadata history and
|
||||||
|
explanation, including deep inherited fields. Sensitivity propagation protects
|
||||||
|
matched and removed evidence and recorded dependencies; detached before/after
|
||||||
|
snapshots remain conservatively masked.
|
||||||
|
|
||||||
|
Correspondence lookup, sensitivity propagation, and resolution-change ordering
|
||||||
|
reuse indexes, a work queue, and prepared pointer strings to avoid repeated
|
||||||
|
prefix construction, full-map passes, and formatting during sorting. No
|
||||||
|
end-to-end performance improvement is claimed.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible extension of resolution auditing and inspection.
|
||||||
|
Existing callbacks that record a whole list remain supported. Records and helper
|
||||||
|
targets for surviving values refer to final positions; helpers do not follow
|
||||||
|
later moves automatically. `Resolution.EntryInfo` and `Resolution.IsExplicit`
|
||||||
|
retain their fixed entry-snapshot meaning.
|
||||||
|
|
||||||
|
Public effective-configuration diffs continue to report list changes atomically.
|
||||||
|
Source composition, collection merge operations, configuration syntax, and public
|
||||||
|
path addressing are unchanged. No new public Go symbols are required.
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.27.0, and the YAML dependency is unchanged.
|
||||||
|
Linux amd64 and arm64 remain release-validated; macOS remains best-effort.
|
||||||
|
|
||||||
|
## Release tooling
|
||||||
|
|
||||||
|
ReleaseKit advances from v0.6.0 to v0.8.0. The runbook uses the module-selected
|
||||||
|
`go tool releasekit` command and documents saved publication attempts and
|
||||||
|
reconciliation after failed or uncertain tag pushes.
|
||||||
|
|
||||||
|
ReleaseKit now checks committed Markdown links and rejects filesystem dependency
|
||||||
|
replacements during candidate validation. The custom documentation-link Go test
|
||||||
|
is removed, so automated link feedback occurs during candidate validation rather
|
||||||
|
than ordinary Go tests. Source publication and hosted Gitea delivery remain
|
||||||
|
separate outcomes; CI remains outside release gates. See the
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/oss/attribution.md)
|
||||||
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.
|
Keeping `Path` solely as an opaque query/building value remains an alternative.
|
||||||
No current contract changes or implementation commitment follow from this entry;
|
No current contract changes or implementation commitment follow from this entry;
|
||||||
a comparison operation can be added compatibly later.
|
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"
|
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
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
// 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 (
|
require (
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
go.maximumdirect.net/releasekit v0.6.0 // indirect
|
go.maximumdirect.net/releasekit v0.8.0 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
|||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.maximumdirect.net/releasekit v0.6.0 h1:wbzMeQGeLjXigBSN9j9iJLf/CC3htjzt9iEwubezgy0=
|
go.maximumdirect.net/releasekit v0.8.0 h1:hSaHPbUI7tAhnolfbu7UMj2fvmm9FGBY+jhkygdbxR0=
|
||||||
go.maximumdirect.net/releasekit v0.6.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
go.maximumdirect.net/releasekit v0.8.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type Explanation struct {
|
|||||||
// or historical path, hiding sensitive information. History follows processing
|
// or historical path, hiding sensitive information. History follows processing
|
||||||
// order: inheritance appears after source combination and before the transition
|
// order: inheritance appears after source combination and before the transition
|
||||||
// to typed values. Unknown and malformed paths return false.
|
// to typed values. Unknown and malformed paths return false.
|
||||||
|
// Surviving registered records use their matched resolution-entry history.
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type tierAcquisitionState struct {
|
|||||||
|
|
||||||
type inputAcquisitionRequest struct {
|
type inputAcquisitionRequest struct {
|
||||||
input loadInput
|
input loadInput
|
||||||
|
resolved *resolvedFilesystemContext
|
||||||
|
withoutBase bool
|
||||||
role sourceVersionRole
|
role sourceVersionRole
|
||||||
directiveContext rootDirectiveContext
|
directiveContext rootDirectiveContext
|
||||||
protectionRule *VersionRule
|
protectionRule *VersionRule
|
||||||
@@ -33,6 +35,9 @@ type importAcquisitionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic) {
|
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
|
source := request.input.source
|
||||||
destination := request.input.destination
|
destination := request.input.destination
|
||||||
accounting := state.accounting
|
accounting := state.accounting
|
||||||
@@ -101,7 +106,12 @@ func acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, sta
|
|||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
}
|
}
|
||||||
request.input.source = selectedSource
|
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}
|
importRequest := importAcquisitionRequest{input: request, main: parsed, processing: versionProcessing}
|
||||||
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
input.composition, input.sources, input.resolvedFilesystem, diagnostics = acquireImportedInput(importRequest, &policy, state, tier)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -126,6 +136,12 @@ func acquireStandaloneInput(input assembledInput, request inputAcquisitionReques
|
|||||||
if processing != nil {
|
if processing != nil {
|
||||||
input.document = processing.payload
|
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)) {
|
if request.input.filesystem != nil && (input.source.kind == sourceNone || !activeOverlay(request.ordinaryOverlay)) {
|
||||||
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
resolved, diagnostic := validateFilesystemContext(input.source, request.input.filesystem, parsed, tier.hostToken)
|
||||||
if diagnostic != nil {
|
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 (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"go.maximumdirect.net/configkit/internal/document"
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
type documentDeltaKind uint8
|
type documentDeltaKind uint8
|
||||||
@@ -24,6 +25,10 @@ type documentDelta struct {
|
|||||||
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||||
var deltas []documentDelta
|
var deltas []documentDelta
|
||||||
compareConfigurationValue(before, after, Path{}, &deltas)
|
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||||
|
return sortDocumentDeltas(deltas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortDocumentDeltas(deltas []documentDelta) []documentDelta {
|
||||||
type orderedDelta struct {
|
type orderedDelta struct {
|
||||||
delta documentDelta
|
delta documentDelta
|
||||||
pointer string
|
pointer string
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
}
|
}
|
||||||
selections := model.indexReplaySelections(selected)
|
selections := model.indexReplaySelections(selected)
|
||||||
|
coordinate := model.queryCoordinates(path)
|
||||||
|
entryPath := coordinate.entry
|
||||||
|
if !coordinate.hasEntry {
|
||||||
|
entryPath = Path{invalid: true}
|
||||||
|
}
|
||||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)}
|
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, model.documentLookup)}
|
||||||
if info, ok := r.metadata.Lookup(path); ok {
|
if info, ok := r.metadata.Lookup(path); ok {
|
||||||
explanation.Origin = cloneOrigin(info.Origin)
|
explanation.Origin = cloneOrigin(info.Origin)
|
||||||
@@ -57,7 +62,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
working := document.Clone(model.baseline)
|
working := document.Clone(model.baseline)
|
||||||
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
|
if baseline, found := selections.byPayload[historyPayloadRef{phase: historyPhaseBaseline, id: 0}]; found && baseline.after {
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
After: valueSnapshot(working, path, r.secrets),
|
After: valueSnapshot(working, entryPath, r.secrets),
|
||||||
Origin: cloneOrigin(baseline.attribution.origin),
|
Origin: cloneOrigin(baseline.attribution.origin),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,13 +72,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
application := payload.application
|
application := payload.application
|
||||||
before := valueSnapshot(working, path, r.secrets)
|
before := valueSnapshot(working, entryPath, r.secrets)
|
||||||
applyHistoryApplication(&working, application, replayIndex)
|
applyHistoryApplication(&working, application, replayIndex)
|
||||||
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
|
selected, relevant := selections.application(historyPayloadRef{phase: historyPhaseSources, id: payloadID})
|
||||||
if !relevant {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
after := valueSnapshot(working, entryPath, r.secrets)
|
||||||
if !before.Present && !after.Present {
|
if !before.Present && !after.Present {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -83,20 +88,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
Origin: cloneOrigin(selected.attribution.origin),
|
Origin: cloneOrigin(selected.attribution.origin),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
appendInheritanceExplanation(&explanation, &working, path, model, selections, r.secrets)
|
appendInheritanceExplanation(&explanation, &working, entryPath, model, selections, r.secrets)
|
||||||
|
|
||||||
// Intermediate input replacements do not permanently suppress defaults:
|
// Intermediate input replacements do not permanently suppress defaults:
|
||||||
// decoding applies them to the final input tree. Show that reconciliation
|
// decoding applies them to the final input tree. Show that reconciliation
|
||||||
// explicitly before typed CLI contributions and domain resolution.
|
// explicitly before typed CLI contributions and domain resolution.
|
||||||
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
|
if checkpoint := model.preCLICheckpoint; checkpoint != nil {
|
||||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
|
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePreCLI, id: 0}]
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, entryPath)
|
||||||
decoded, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
decoded, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
|
||||||
if selected && !configurationValuesEqual(replayed, decoded) {
|
if selected && !configurationValuesEqual(replayed, decoded) {
|
||||||
origin, reasons := checkpointExplanationAttribution(selection)
|
origin, reasons := checkpointExplanationAttribution(selection)
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
Before: valueSnapshot(working, entryPath, r.secrets),
|
||||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
Reasons: reasons,
|
Reasons: reasons,
|
||||||
})
|
})
|
||||||
@@ -109,13 +114,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
|
applicationRef := historyPayloadRef{phase: historyPhaseCLI, id: payloadID}
|
||||||
before := valueSnapshot(working, path, r.secrets)
|
before := valueSnapshot(working, entryPath, r.secrets)
|
||||||
applyHistoryApplication(&working, payload.application, replayIndex)
|
applyHistoryApplication(&working, payload.application, replayIndex)
|
||||||
selected, relevant := selections.application(applicationRef)
|
selected, relevant := selections.application(applicationRef)
|
||||||
if !relevant {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
after := valueSnapshot(working, entryPath, r.secrets)
|
||||||
if !before.Present && !after.Present {
|
if !before.Present && !after.Present {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -127,13 +132,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
}
|
}
|
||||||
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
|
if checkpoint := model.postCLICheckpoint; checkpoint != nil {
|
||||||
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
|
selection, selected := selections.byPayload[historyPayloadRef{phase: historyPhasePostCLI, id: 0}]
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, entryPath)
|
||||||
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, path, model.documentLookup)
|
effectiveInput, _ := documentAtPathWithLookup(checkpoint.root, entryPath, model.documentLookup)
|
||||||
if selected && !configurationValuesEqual(replayed, effectiveInput) {
|
if selected && !configurationValuesEqual(replayed, effectiveInput) {
|
||||||
origin, reasons := checkpointExplanationAttribution(selection)
|
origin, reasons := checkpointExplanationAttribution(selection)
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
Before: valueSnapshot(working, entryPath, r.secrets),
|
||||||
After: valueSnapshotWithLookup(checkpoint.root, path, r.secrets, model.documentLookup),
|
After: valueSnapshotWithLookup(checkpoint.root, entryPath, r.secrets, model.documentLookup),
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
Reasons: reasons,
|
Reasons: reasons,
|
||||||
})
|
})
|
||||||
@@ -147,7 +152,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
covering := model.resolutionPayload.snapshots[group.coveringRecordID]
|
covering := model.resolutionPayload.snapshots[group.coveringRecordID]
|
||||||
before := valueSnapshot(working, path, r.secrets)
|
before := valueSnapshot(working, entryPath, r.secrets)
|
||||||
replaceHistoryPath(&working, group.change.Path, covering.after)
|
replaceHistoryPath(&working, group.change.Path, covering.after)
|
||||||
after := valueSnapshot(working, path, r.secrets)
|
after := valueSnapshot(working, path, r.secrets)
|
||||||
if !before.Present && !after.Present {
|
if !before.Present && !after.Present {
|
||||||
|
|||||||
@@ -17,23 +17,30 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
|||||||
}
|
}
|
||||||
return resolvedFilesystemContext{}, diagnostic
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
}
|
}
|
||||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
if diagnostic := validateResolvedFilesystemContext(resolved); diagnostic != nil {
|
||||||
return resolvedFilesystemContext{}, diagnostic
|
return resolvedFilesystemContext{}, diagnostic
|
||||||
}
|
}
|
||||||
if resolved.native != nil {
|
|
||||||
return resolved, nil
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResolvedFilesystemContext(resolved resolvedFilesystemContext) *Diagnostic {
|
||||||
|
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
if resolved.native != nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(resolved.rootPath)
|
root, err := os.OpenRoot(resolved.rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return resolvedFilesystemContext{}, &value
|
return &value
|
||||||
}
|
}
|
||||||
if err := root.Close(); err != nil {
|
if err := root.Close(); err != nil {
|
||||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
return resolvedFilesystemContext{}, &value
|
return &value
|
||||||
}
|
}
|
||||||
return resolved, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ type resolutionReplayGroup struct {
|
|||||||
type historyResolutionPayload struct {
|
type historyResolutionPayload struct {
|
||||||
snapshots []resolutionSnapshot
|
snapshots []resolutionSnapshot
|
||||||
replayGroups []resolutionReplayGroup
|
replayGroups []resolutionReplayGroup
|
||||||
|
correspondence resolutionCorrespondence
|
||||||
}
|
}
|
||||||
|
|
||||||
// observeResolutionEvidence retains audited records in producer order and
|
// observeResolutionEvidence retains audited records in producer order and
|
||||||
// indexes the concrete shapes in every original before/after subtree. Snapshot
|
// indexes the concrete shapes in every original before/after subtree. Snapshot
|
||||||
// roots are already detached by runResolution and are shared with the replay
|
// roots are already detached by runResolution and are shared with the replay
|
||||||
// group that refers to their covering record.
|
// group that refers to their covering record.
|
||||||
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot) {
|
func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionSnapshot, correspondence ...resolutionCorrespondence) {
|
||||||
builder.requireOpen()
|
builder.requireOpen()
|
||||||
if len(snapshots) == 0 {
|
if len(snapshots) == 0 {
|
||||||
return
|
return
|
||||||
@@ -36,10 +37,14 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
|
|||||||
snapshots: make([]resolutionSnapshot, len(snapshots)),
|
snapshots: make([]resolutionSnapshot, len(snapshots)),
|
||||||
replayGroups: coalescedResolutionSnapshots(snapshots),
|
replayGroups: coalescedResolutionSnapshots(snapshots),
|
||||||
}
|
}
|
||||||
|
if len(correspondence) != 0 {
|
||||||
|
payload.correspondence = correspondence[0].clone()
|
||||||
|
}
|
||||||
attributions := make([]historyAttributionRef, len(snapshots))
|
attributions := make([]historyAttributionRef, len(snapshots))
|
||||||
lookup := buildDocumentLookup(builder.scratch)
|
lookup := buildDocumentLookup(builder.scratch)
|
||||||
for recordID, snapshot := range snapshots {
|
for recordID, snapshot := range snapshots {
|
||||||
snapshot.change = cloneChange(snapshot.change)
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
|
snapshot.entryPath = snapshot.entryPath.clone()
|
||||||
payload.snapshots[recordID] = snapshot
|
payload.snapshots[recordID] = snapshot
|
||||||
|
|
||||||
ref := builder.nextPayload(historyPhaseResolution)
|
ref := builder.nextPayload(historyPhaseResolution)
|
||||||
@@ -48,8 +53,12 @@ func (builder *historyBuilder) observeResolutionEvidence(snapshots []resolutionS
|
|||||||
}
|
}
|
||||||
attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies)
|
attribution := builder.addAttribution(snapshot.change.Origin, snapshot.change.Reasons, snapshot.change.Dependencies)
|
||||||
attributions[recordID] = attribution
|
attributions[recordID] = attribution
|
||||||
beforePath, beforeFound := historyConcretePathAt(builder.scratch, snapshot.change.Path, lookup)
|
entryPath, hasEntry := snapshot.change.Path, true
|
||||||
if beforeFound {
|
if len(correspondence) != 0 {
|
||||||
|
entryPath, hasEntry = snapshot.entryPath, snapshot.hasEntry
|
||||||
|
}
|
||||||
|
beforePath, beforeFound := historyConcretePathAt(builder.scratch, entryPath, lookup)
|
||||||
|
if hasEntry && beforeFound {
|
||||||
observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false)
|
observeResolutionSubtree(builder, snapshot.before, beforePath, snapshot.change.Path, ref, attribution, true, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,21 +26,43 @@ type historySensitivityCandidate struct {
|
|||||||
classify bool
|
classify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type historyQueryCoordinates struct {
|
||||||
|
final Path
|
||||||
|
entry Path
|
||||||
|
hasEntry bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *historyModel) queryCoordinates(path Path) historyQueryCoordinates {
|
||||||
|
coordinate := historyQueryCoordinates{final: path.clone(), entry: path.clone(), hasEntry: true}
|
||||||
|
if model != nil && model.resolutionPayload != nil {
|
||||||
|
coordinate.entry, coordinate.hasEntry = model.resolutionPayload.correspondence.entryPath(path)
|
||||||
|
}
|
||||||
|
return coordinate
|
||||||
|
}
|
||||||
|
|
||||||
func (model *historyModel) selectHistory(path Path) []historySelection {
|
func (model *historyModel) selectHistory(path Path) []historySelection {
|
||||||
if model == nil || !path.valid() || !model.hasObservedShape(path) {
|
if model == nil || !path.valid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
coordinate := model.queryCoordinates(path)
|
||||||
|
if !model.hasObservedShape(path) && (!coordinate.hasEntry || !model.hasObservedShape(coordinate.entry)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
candidateSet := make(map[int]struct{})
|
candidateSet := make(map[int]struct{})
|
||||||
for _, observationID := range model.observationsByPointer[path.String()] {
|
collect := func(query Path) {
|
||||||
|
for _, observationID := range model.observationsByPointer[query.String()] {
|
||||||
candidateSet[observationID] = struct{}{}
|
candidateSet[observationID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
model.affectedPathIndex.visitDescendants(query.String(), func(observationID int) { candidateSet[observationID] = struct{}{} })
|
||||||
|
}
|
||||||
|
collect(path)
|
||||||
|
if coordinate.hasEntry {
|
||||||
|
collect(coordinate.entry)
|
||||||
|
}
|
||||||
// Producers register all affected before/after paths. Exact facts cover
|
// Producers register all affected before/after paths. Exact facts cover
|
||||||
// ancestor replacements; descendants cover operations within an enclosing
|
// ancestor replacements; descendants cover operations within an enclosing
|
||||||
// query. Logical operations are still deduplicated by payload below.
|
// query. Logical operations are still deduplicated by payload below.
|
||||||
model.affectedPathIndex.visitDescendants(path.String(), func(observationID int) {
|
|
||||||
candidateSet[observationID] = struct{}{}
|
|
||||||
})
|
|
||||||
candidates := make([]int, 0, len(candidateSet))
|
candidates := make([]int, 0, len(candidateSet))
|
||||||
for observationID := range candidateSet {
|
for observationID := range candidateSet {
|
||||||
candidates = append(candidates, observationID)
|
candidates = append(candidates, observationID)
|
||||||
@@ -54,18 +76,30 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
|
|||||||
if observation.role == historyRoleInventory {
|
if observation.role == historyRoleInventory {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
query := path
|
||||||
|
if observation.phase != historyPhaseResolution {
|
||||||
|
if !coordinate.hasEntry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query = coordinate.entry
|
||||||
|
} else if observation.beforePresent && !observation.afterPresent {
|
||||||
|
if !coordinate.hasEntry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query = coordinate.entry
|
||||||
|
}
|
||||||
reportedPath := observation.reportedPath
|
reportedPath := observation.reportedPath
|
||||||
attributionRef := observation.attributionRef
|
attributionRef := observation.attributionRef
|
||||||
if observation.role == historyRoleContribution {
|
if observation.role == historyRoleContribution {
|
||||||
if !historyObservationMatchesQuery(path, observation.path) {
|
if !historyObservationMatchesQuery(query, observation.path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
application, found := model.applicationPayload(observation.payload)
|
application, found := model.applicationPayload(observation.payload)
|
||||||
if !found {
|
if !found {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if pathContains(observation.path, path) && len(observation.path.segments) < len(path.segments) {
|
if pathContains(observation.path, query) && len(observation.path.segments) < len(query.segments) {
|
||||||
local, ok := historyApplicationLocalPath(application, path)
|
local, ok := historyApplicationLocalPath(application, query)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -73,12 +107,17 @@ func (model *historyModel) selectHistory(path Path) []historySelection {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if operationPath, relevant := applicationOperationPath(application, path, model.documentLookup); relevant {
|
if operationPath, relevant := applicationOperationPath(application, query, model.documentLookup); relevant {
|
||||||
reportedPath = operationPath
|
reportedPath = operationPath
|
||||||
}
|
}
|
||||||
} else if !historyObservationMatchesQuery(path, observation.path) {
|
} else if !historyObservationMatchesQuery(query, observation.path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if observation.phase != historyPhaseResolution && query.String() != path.String() {
|
||||||
|
if translated, matched := model.resolutionPayload.correspondence.finalPath(reportedPath); matched {
|
||||||
|
reportedPath = translated
|
||||||
|
}
|
||||||
|
}
|
||||||
selectionIndex, found := selectionByPayload[observation.payload]
|
selectionIndex, found := selectionByPayload[observation.payload]
|
||||||
if !found {
|
if !found {
|
||||||
selectionIndex = len(selections)
|
selectionIndex = len(selections)
|
||||||
|
|||||||
@@ -38,26 +38,30 @@ func acquireImportedInput(request importAcquisitionRequest, policy *acquisitionP
|
|||||||
source := request.input.input.source
|
source := request.input.input.source
|
||||||
context := request.input.input.filesystem
|
context := request.input.input.filesystem
|
||||||
main := request.main
|
main := request.main
|
||||||
resolved, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
resolved := request.input.resolved
|
||||||
|
if resolved == nil {
|
||||||
|
value, diagnostic := resolveFilesystemContext(source, context, main, tier.hostToken)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
|
resolved = &value
|
||||||
|
}
|
||||||
if resolved.native == nil {
|
if resolved.native == nil {
|
||||||
if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil {
|
if diagnostic := validateResolvedFilesystemDirectories(*resolved); diagnostic != nil {
|
||||||
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
return nil, sourceInfos(main.source), nil, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
access, err := openResolvedFilesystemAccess(resolved)
|
access, err := openResolvedFilesystemAccess(*resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
diagnostic.Primary = nil
|
diagnostic.Primary = nil
|
||||||
return nil, sourceInfos(main.source), nil, []Diagnostic{diagnostic}
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
return composition, sources, nil, diagnostics
|
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) {
|
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()
|
_ = closeAccess()
|
||||||
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
|
||||||
}
|
}
|
||||||
accounting.sources++
|
|
||||||
accounting.bytes += main.bytes
|
|
||||||
accounting.nodes += countInputNodes(main.document)
|
|
||||||
directiveContext := request.input.directiveContext
|
directiveContext := request.input.directiveContext
|
||||||
if directiveContext == 0 {
|
if directiveContext == 0 {
|
||||||
directiveContext = rootDirectiveContextImportsOnly
|
directiveContext = rootDirectiveContextImportsOnly
|
||||||
@@ -120,7 +121,6 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
request.input.directiveContext = directiveContext
|
request.input.directiveContext = directiveContext
|
||||||
state := importTraversal{
|
state := importTraversal{
|
||||||
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
access: access, request: request, policy: policy, state: loadState, tier: tier,
|
||||||
scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()},
|
|
||||||
sources: []SourceInfo{mainInfo},
|
sources: []SourceInfo{mainInfo},
|
||||||
active: make(map[resourceIdentity]int),
|
active: make(map[resourceIdentity]int),
|
||||||
importGraph: newImportInclusionGraph(),
|
importGraph: newImportInclusionGraph(),
|
||||||
@@ -129,11 +129,10 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
state.itemScope = true
|
state.itemScope = true
|
||||||
state.itemPrefix = destination.clone()
|
state.itemPrefix = destination.clone()
|
||||||
state.itemFallback = destination.clone()
|
state.itemFallback = destination.clone()
|
||||||
state.scope.destination = Path{}
|
|
||||||
state.sources[0] = cloneSourceInfo(mainInfo)
|
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
|
if diagnostic := chargeDeclaringImport(main, accounting, limits); diagnostic != nil {
|
||||||
_ = closeAccess()
|
_ = closeAccess()
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
@@ -159,31 +158,18 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
} else {
|
} else {
|
||||||
body, declarations, diagnostics = splitImportDirectives(main.document)
|
body, declarations, diagnostics = splitImportDirectives(main.document)
|
||||||
}
|
}
|
||||||
if len(diagnostics) == 0 {
|
root := acquiredContribution{
|
||||||
if mainIsItem {
|
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
||||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
role: importRoleMain, scopeOwner: mainNodeKey, processing: processing,
|
||||||
source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain,
|
sequenceDeclarations: sequenceDeclarations,
|
||||||
scopeOwner: mainNodeKey, payload: body, document: body, processing: processing,
|
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
|
||||||
})
|
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}, 0)
|
|
||||||
} else {
|
|
||||||
_, placed, err := placeDocument(body, destination, body.Location, maxDocumentDepth, maxDocumentNodes)
|
|
||||||
placementNodes := destinationWrapperNodeCount(destination)
|
|
||||||
if err != nil {
|
|
||||||
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
|
|
||||||
} else if accounting.nodes+placementNodes > limits.nodes {
|
|
||||||
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
|
|
||||||
} else {
|
|
||||||
accounting.nodes += placementNodes
|
|
||||||
state.scope.contributions = append(state.scope.contributions, acquiredContribution{
|
|
||||||
source: cloneSourceInfo(mainInfo), payload: body, document: placed,
|
|
||||||
identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey,
|
|
||||||
destination: destination.clone(), processing: processing,
|
|
||||||
sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations),
|
|
||||||
})
|
|
||||||
diagnostics = state.walkDeclarations(basePath, declarations, 0, destination, 0)
|
|
||||||
}
|
}
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
scope, diagnostic := prepareDeclaringImportScope(root, accounting, limits)
|
||||||
|
if diagnostic != nil {
|
||||||
|
diagnostics = []Diagnostic{*diagnostic}
|
||||||
|
} else {
|
||||||
|
state.scope = scope
|
||||||
|
diagnostics = state.walkDeclarations(basePath, declarations, 0, scope.destination, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
@@ -200,19 +186,8 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
|
||||||
}
|
}
|
||||||
acquisition := state.scope
|
acquisition, diagnostic := finalizeDeclaringImportScope(state.scope, root, accounting, limits)
|
||||||
if mainIsItem {
|
if diagnostic != nil {
|
||||||
localScope := state.scope
|
|
||||||
acquisition = acquisitionScope{
|
|
||||||
owner: mainNodeKey, destination: destination.clone(),
|
|
||||||
contributions: []acquiredContribution{{
|
|
||||||
source: cloneSourceInfo(mainInfo), payload: body, identity: mainNodeKey,
|
|
||||||
role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(),
|
|
||||||
processing: processing, sequenceDeclarations: cloneSequenceDeclarations(sequenceDeclarations), pendingItem: &localScope,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if diagnostic := reservePendingItemPlacements(&acquisition, limits, accounting); diagnostic != nil {
|
|
||||||
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
return &composedDocument{
|
return &composedDocument{
|
||||||
@@ -221,35 +196,27 @@ func acquireImportedWithAccess(request importAcquisitionRequest, policy *acquisi
|
|||||||
}, cloneSourceInfos(state.sources), nil
|
}, cloneSourceInfos(state.sources), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
func prepareImportDeclarations(declarations []importDeclarationSpec, defaults ImportDefaults, parentDestination Path) ([]resolvedImportPolicy, []Diagnostic) {
|
||||||
if s.state.accounting.sources > s.policy.importLimits.sources {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if s.state.accounting.bytes > s.policy.importLimits.bytes {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
if s.state.accounting.nodes > s.policy.importLimits.nodes {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclarationSpec, depth int, parentDestination Path, sourceIndex int) []Diagnostic {
|
|
||||||
policies := make([]resolvedImportPolicy, len(declarations))
|
policies := make([]resolvedImportPolicy, len(declarations))
|
||||||
for index, spec := range declarations {
|
for index, spec := range declarations {
|
||||||
policy, diagnostics := resolveImportPolicy(spec, s.policy.importDefaults)
|
policy, diagnostics := resolveImportPolicy(spec, defaults)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return nil, diagnostics
|
||||||
}
|
}
|
||||||
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
if policy.placement == PlacementModeSequenceItem && len(appendDestination(parentDestination, policy.destination).segments) == 0 {
|
||||||
site := spec.fieldSites["placement"]
|
site := spec.fieldSites["placement"]
|
||||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: site.path.clone(), Message: "sequence-item placement requires a nonempty destination", Primary: cloneLocation(&site.location)}}
|
return 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
|
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 {
|
for index, spec := range declarations {
|
||||||
policy := policies[index]
|
policy := policies[index]
|
||||||
var selection importSelection
|
var selection importSelection
|
||||||
|
|||||||
@@ -285,3 +285,58 @@ func importedVersionClaim(documents []*document.Node, scope acquisitionScope, co
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
if spec.patternSet {
|
||||||
policy.pattern = cloneStrings(spec.pattern)
|
policy.pattern = cloneStrings(spec.pattern)
|
||||||
} else {
|
} 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 {
|
if spec.depthSet && !policy.recursive {
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ type Input struct {
|
|||||||
Source Source
|
Source Source
|
||||||
Imports bool
|
Imports bool
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
|
MainRelative *MainRelativeContext
|
||||||
Destination Path
|
Destination Path
|
||||||
PlacementMode PlacementMode
|
PlacementMode PlacementMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MainRelativeContext struct {
|
||||||
|
Fallback *FilesystemContext
|
||||||
|
RequireBase bool
|
||||||
|
}
|
||||||
|
|
||||||
// Underlay contains strictly composed contributions at one precedence tier.
|
// Underlay contains strictly composed contributions at one precedence tier.
|
||||||
type Underlay struct{ Inputs []Input }
|
type Underlay struct{ Inputs []Input }
|
||||||
|
|
||||||
@@ -22,6 +28,7 @@ type LayeredOptions[T any] struct {
|
|||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
|
OverlayFallback *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +44,14 @@ func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOp
|
|||||||
}
|
}
|
||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{
|
main: loadInput{
|
||||||
source: main.Source, imports: main.Imports, filesystem: main.Filesystem,
|
source: main.Source, imports: main.Imports, filesystem: main.Filesystem, mainRelative: main.MainRelative,
|
||||||
destination: main.Destination.clone(), placementMode: main.PlacementMode,
|
destination: main.Destination.clone(), placementMode: main.PlacementMode,
|
||||||
},
|
},
|
||||||
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
definition: options.Definition, sourceVersionMode: options.SourceVersionMode,
|
||||||
importDefaults: options.ImportDefaults,
|
importDefaults: options.ImportDefaults,
|
||||||
overlay: options.Overlay,
|
overlay: options.Overlay,
|
||||||
overlayFilesystem: options.OverlayFilesystem,
|
overlayFilesystem: options.OverlayFilesystem,
|
||||||
|
overlayFallback: options.OverlayFallback,
|
||||||
overrides: append([]Override(nil), options.Overrides...),
|
overrides: append([]Override(nil), options.Overrides...),
|
||||||
underlays: make([]loadGroup, len(options.Underlays)),
|
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))
|
request.underlays[index].inputs = make([]loadInput, len(underlay.Inputs))
|
||||||
for inputIndex, input := range underlay.Inputs {
|
for inputIndex, input := range underlay.Inputs {
|
||||||
request.underlays[index].inputs[inputIndex] = loadInput{
|
request.underlays[index].inputs[inputIndex] = loadInput{
|
||||||
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
|
source: input.Source, imports: input.Imports, filesystem: input.Filesystem, mainRelative: input.MainRelative,
|
||||||
destination: input.Destination.clone(), placementMode: input.PlacementMode,
|
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()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
|
postDecode := preparePostDecodeOverrideContext(input, decoded, assigned)
|
||||||
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode)
|
resolved, diagnostics := resolveLoadInput(schema, request.definition, input, postDecode, request.sequencePolicy)
|
||||||
accumulator.add(diagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if hasErrors(diagnostics) {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ func cloneMetadata(metadata Metadata) Metadata {
|
|||||||
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) {
|
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entry resolutionInput) {
|
||||||
finalPaths := make(map[string]*document.Node)
|
finalPaths := make(map[string]*document.Node)
|
||||||
indexDocument(resolved.final, Path{}, finalPaths)
|
indexDocument(resolved.final, Path{}, finalPaths)
|
||||||
|
fields := make(map[string]FieldInfo, len(finalPaths))
|
||||||
|
walkDocument(resolved.final, Path{}, func(path Path, _ *document.Node) {
|
||||||
|
if prior, matched := resolved.correspondence.entryPath(path); matched {
|
||||||
|
if info, found := entry.lookup(prior); found {
|
||||||
|
fields[path.String()] = cloneFieldInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
metadata.fields = fields
|
||||||
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
|
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
|
||||||
var deltaCoverage pathPrefixIndex
|
var deltaCoverage pathPrefixIndex
|
||||||
snapshotIDs := make([]int, len(resolved.deltas))
|
snapshotIDs := make([]int, len(resolved.deltas))
|
||||||
@@ -163,7 +172,7 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult, entr
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
|
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
|
||||||
refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage)
|
refreshResolutionFields(metadata.fields, finalPaths, resolved.final, entry, resolved.snapshots, snapshotCoverage, resolved.correspondence)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
|
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
|
||||||
@@ -192,7 +201,7 @@ func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) {
|
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, final *document.Node, entry resolutionInput, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex, correspondence resolutionCorrespondence) {
|
||||||
for pointer := range fields {
|
for pointer := range fields {
|
||||||
if _, ok := finalPaths[pointer]; !ok {
|
if _, ok := finalPaths[pointer]; !ok {
|
||||||
delete(fields, pointer)
|
delete(fields, pointer)
|
||||||
@@ -200,7 +209,9 @@ func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]
|
|||||||
}
|
}
|
||||||
walkDocument(final, Path{}, func(path Path, node *document.Node) {
|
walkDocument(final, Path{}, func(path Path, node *document.Node) {
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
entryInfo, entryFound := entry.lookup(path)
|
prior, matched := correspondence.entryPath(path)
|
||||||
|
entryInfo, entryFound := entry.lookup(prior)
|
||||||
|
entryFound = entryFound && matched
|
||||||
if info, ok := fields[pointer]; ok {
|
if info, ok := fields[pointer]; ok {
|
||||||
info.Explicit = entryFound && entryInfo.Explicit
|
info.Explicit = entryFound && entryInfo.Explicit
|
||||||
info.Null = node.Kind == document.Null
|
info.Null = node.Kind == document.Null
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type loadRequest[T any] struct {
|
|||||||
effectiveVersion *VersionRule
|
effectiveVersion *VersionRule
|
||||||
overlay string
|
overlay string
|
||||||
overlayFilesystem *FilesystemContext
|
overlayFilesystem *FilesystemContext
|
||||||
|
overlayFallback *FilesystemContext
|
||||||
overrides []Override
|
overrides []Override
|
||||||
ordinary bool
|
ordinary bool
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@ type loadInput struct {
|
|||||||
source Source
|
source Source
|
||||||
imports bool
|
imports bool
|
||||||
filesystem *FilesystemContext
|
filesystem *FilesystemContext
|
||||||
|
mainRelative *MainRelativeContext
|
||||||
destination Path
|
destination Path
|
||||||
placementMode PlacementMode
|
placementMode PlacementMode
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,9 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
|||||||
request.importDefaults = defaults
|
request.importDefaults = defaults
|
||||||
request.sequencePolicy = policy
|
request.sequencePolicy = policy
|
||||||
request.definition.sequenceIdentityRegistrations = nil
|
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, "")
|
main, mainDiagnostics := normalizeInput(request.main, "")
|
||||||
request.main = main
|
request.main = main
|
||||||
diagnostics = append(diagnostics, mainDiagnostics...)
|
diagnostics = append(diagnostics, mainDiagnostics...)
|
||||||
@@ -64,6 +69,13 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
|||||||
for groupIndex := range request.underlays {
|
for groupIndex := range request.underlays {
|
||||||
for inputIndex := range request.underlays[groupIndex].inputs {
|
for inputIndex := range request.underlays[groupIndex].inputs {
|
||||||
input := &request.underlays[groupIndex].inputs[inputIndex]
|
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, "")
|
normalized, inputDiagnostics := normalizeInput(*input, "")
|
||||||
*input = normalized
|
*input = normalized
|
||||||
diagnostics = append(diagnostics, inputDiagnostics...)
|
diagnostics = append(diagnostics, inputDiagnostics...)
|
||||||
@@ -71,10 +83,10 @@ func loadGroupedWithSecretLookupLimit[T any](request loadRequest[T], secrets Sec
|
|||||||
}
|
}
|
||||||
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
diagnostics = append(diagnostics, overlayOptionDiagnostics(request.overlay)...)
|
||||||
if activeOverlay(request.overlay) {
|
if activeOverlay(request.overlay) {
|
||||||
if request.overlayFilesystem == nil {
|
if request.overlayFilesystem != nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
|
||||||
} else {
|
|
||||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFilesystem)...)
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -90,7 +102,7 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p
|
|||||||
projectVersion := sequenceVersionPath(request.protectionVersion())
|
projectVersion := sequenceVersionPath(request.protectionVersion())
|
||||||
for index, group := range request.underlays {
|
for index, group := range request.underlays {
|
||||||
evidenceStart := len(input.versionState.evidenceSince(0))
|
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)
|
mergeResourceContexts(&input.contexts, acquired.contexts)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
input.sources = append(input.sources, acquired.sources...)
|
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.inputBytes = state.accounting.bytes
|
||||||
assembly.current.inputNodes = state.accounting.nodes
|
assembly.current.inputNodes = state.accounting.nodes
|
||||||
if activeOverlay(request.overlay) {
|
if activeOverlay(request.overlay) {
|
||||||
if request.overlayFilesystem == nil {
|
var resolved resolvedFilesystemContext
|
||||||
return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
|
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 {
|
if diagnostic != nil {
|
||||||
return input, append(versionDiagnostics, *diagnostic)
|
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(),
|
name: request.overlay, protectionRule: request.protectionVersion(),
|
||||||
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState,
|
||||||
}
|
}
|
||||||
|
if useBase {
|
||||||
|
overlayRequest.lookupDirectory = resolved.basePath
|
||||||
|
}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
access, diagnostic := openSelectedOverlayAccess(resolved)
|
access, diagnostic := openSelectedOverlayAccess(resolved)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -248,12 +273,31 @@ func applyCompletedTier(
|
|||||||
return operations, nil
|
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
|
initialBytes, initialNodes := state.accounting.bytes, state.accounting.nodes
|
||||||
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
tier := &tierAcquisitionState{visited: make(map[resourceIdentity]importRoute), hostToken: &namespaceToken{}}
|
||||||
|
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)
|
nativeAnchors := make(map[*namespaceToken]string)
|
||||||
for inputIndex, contribution := range group.inputs {
|
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 {
|
if _, exists := nativeAnchors[token]; !exists {
|
||||||
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
|
nativeAnchors[token] = explicitNamespaceAnchor(index, inputIndex+1)
|
||||||
}
|
}
|
||||||
@@ -262,8 +306,11 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
|||||||
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
acquiredGroup := &acquiredUnderlayGroup{index: index}
|
||||||
inclusionGraph := newImportInclusionGraph()
|
inclusionGraph := newImportInclusionGraph()
|
||||||
for inputIndex, contribution := range group.inputs {
|
for inputIndex, contribution := range group.inputs {
|
||||||
|
contribution, resolved := bind(contribution)
|
||||||
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
acquired, diagnostics := acquireInput(inputAcquisitionRequest{
|
||||||
input: contribution, role: sourceVersionContribution,
|
input: contribution, role: sourceVersionContribution,
|
||||||
|
resolved: resolved,
|
||||||
|
withoutBase: contribution.mainRelative != nil && contribution.filesystem == nil && resolved == nil,
|
||||||
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
directiveContext: rootDirectiveContextLayered, protectionRule: rule,
|
||||||
}, policy, state, tier)
|
}, policy, state, tier)
|
||||||
for _, source := range acquired.sources {
|
for _, source := range acquired.sources {
|
||||||
@@ -282,7 +329,8 @@ func acquireUnderlayGroupScopes(group loadGroup, index int, rule *VersionRule, s
|
|||||||
kind: "host", host: explicitNamespaceAnchor(index, 0),
|
kind: "host", host: explicitNamespaceAnchor(index, 0),
|
||||||
input: explicitNamespaceAnchor(index, inputIndex+1),
|
input: explicitNamespaceAnchor(index, inputIndex+1),
|
||||||
}
|
}
|
||||||
if token := nativeTokenForLoadInput(contribution); token != nil {
|
token := nativeTokenForLoadInput(contribution, resolved)
|
||||||
|
if token != nil {
|
||||||
anchors.kind = "native"
|
anchors.kind = "native"
|
||||||
anchors.native = nativeAnchors[token]
|
anchors.native = nativeAnchors[token]
|
||||||
}
|
}
|
||||||
@@ -365,7 +413,10 @@ func composeUnderlayGroup(acquired *acquiredUnderlayGroup, rule *VersionRule, po
|
|||||||
return composition, nil
|
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 {
|
if input.source.kind == sourceFilesystem && input.source.namespace != nil {
|
||||||
return input.source.namespace.token
|
return input.source.namespace.token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
|||||||
accounting := &importAccounting{}
|
accounting := &importAccounting{}
|
||||||
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
group := loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}
|
||||||
state := &acquisitionState{accounting: accounting}
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
t.Fatalf("acquire underlay group: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ func TestUnderlayGroupAcquisitionRetainsScopesUntilComposition(t *testing.T) {
|
|||||||
t.Fatalf("composed group handoff = %#v", composition)
|
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 {
|
if len(diagnostics) != 0 || len(empty.scopes) != 0 {
|
||||||
t.Fatalf("empty group acquisition = %#v, %#v", empty, diagnostics)
|
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) {
|
func testAcquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*acquiredUnderlayGroup, []Diagnostic) {
|
||||||
state := &acquisitionState{accounting: accounting}
|
state := &acquisitionState{accounting: accounting}
|
||||||
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()})
|
acquired, diagnostics := acquireUnderlayGroupScopes(group, index, rule, state, acquisitionPolicy{importLimits: defaultImportLimits()}, nil)
|
||||||
return acquired, diagnostics
|
return acquired, diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
return decoded, nil
|
return decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext) (resolvedLoad[T], []Diagnostic) {
|
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, prepared postDecodeOverrideContext, policy sequencePolicy) (resolvedLoad[T], []Diagnostic) {
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
decoded := prepared.decoded
|
decoded := prepared.decoded
|
||||||
configuration := decoded.value.Interface().(T)
|
configuration := decoded.value.Interface().(T)
|
||||||
@@ -54,7 +54,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
var resolutionSnapshots []resolutionSnapshot
|
var resolutionSnapshots []resolutionSnapshot
|
||||||
refreshReferenceInput := false
|
refreshReferenceInput := false
|
||||||
if definition.Resolve != nil {
|
if definition.Resolve != nil {
|
||||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution)
|
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, referenceInput, prepared.resolution, policy)
|
||||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||||
if hasErrors(resolved.diagnostics) {
|
if hasErrors(resolved.diagnostics) {
|
||||||
return resolvedLoad[T]{}, diagnostics
|
return resolvedLoad[T]{}, diagnostics
|
||||||
@@ -62,7 +62,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
final = resolved.final
|
final = resolved.final
|
||||||
resolutionSnapshots = resolved.snapshots
|
resolutionSnapshots = resolved.snapshots
|
||||||
if builder != nil && len(resolutionSnapshots) != 0 {
|
if builder != nil && len(resolutionSnapshots) != 0 {
|
||||||
builder.observeResolutionEvidence(resolutionSnapshots)
|
builder.observeResolutionEvidence(resolutionSnapshots, resolved.correspondence)
|
||||||
}
|
}
|
||||||
applyResolutionMetadata(&metadata, resolved, prepared.resolution)
|
applyResolutionMetadata(&metadata, resolved, prepared.resolution)
|
||||||
finalizeEffectiveMetadataPaths(&metadata, final)
|
finalizeEffectiveMetadataPaths(&metadata, final)
|
||||||
|
|||||||
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.
|
// Unknown and invalid paths return nil.
|
||||||
func (m Metadata) History(path Path) []Change {
|
func (m Metadata) History(path Path) []Change {
|
||||||
model := m.historyEvidence()
|
model := m.historyEvidence()
|
||||||
if model == nil || !model.hasObservedShape(path) {
|
if model == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
selections := model.selectHistory(path)
|
selections := model.selectHistory(path)
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
|||||||
value.Items[0] = "normalized"
|
value.Items[0] = "normalized"
|
||||||
resolution.RecordNormalization(Keys("items"), "normalized item")
|
resolution.RecordNormalization(Keys("items"), "normalized item")
|
||||||
return nil
|
return nil
|
||||||
}, schema, decoded, document, input)
|
}, schema, decoded, document, input, sequencePolicy{})
|
||||||
if len(resolved.diagnostics) != 0 {
|
if len(resolved.diagnostics) != 0 {
|
||||||
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func normalizeInput(input loadInput, ordinaryOverlay string) (loadInput, []Diagn
|
|||||||
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
if input.source.kind == sourceNone && activeOverlay(ordinaryOverlay) && input.filesystem == nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
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"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||||
}
|
}
|
||||||
return input, canonicalDiagnostics(diagnostics)
|
return input, canonicalDiagnostics(diagnostics)
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ func documentWithMetadataLocations(root *document.Node, metadata Metadata, fallb
|
|||||||
}
|
}
|
||||||
var lookup *documentLookup
|
var lookup *documentLookup
|
||||||
fallbackLocation := func(path Path) (document.Location, bool) {
|
fallbackLocation := func(path Path) (document.Location, bool) {
|
||||||
|
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
|
||||||
|
var matched bool
|
||||||
|
path, matched = model.resolutionPayload.correspondence.entryPath(path)
|
||||||
|
if !matched {
|
||||||
|
return document.Location{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
if lookup == nil {
|
if lookup == nil {
|
||||||
lookup = buildDocumentLookup(fallbackRoot)
|
lookup = buildDocumentLookup(fallbackRoot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
|||||||
value.Count = 6
|
value.Count = 6
|
||||||
resolution.RecordNormalization(Keys("count"), "normalize count")
|
resolution.RecordNormalization(Keys("count"), "normalize count")
|
||||||
return nil
|
return nil
|
||||||
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned))
|
}}, input, preparePostDecodeOverrideContext(input, decoded, assigned), sequencePolicy{})
|
||||||
if len(resolveDiagnostics) != 0 {
|
if len(resolveDiagnostics) != 0 {
|
||||||
t.Fatalf("resolveLoadInput() diagnostics = %#v", resolveDiagnostics)
|
t.Fatalf("resolveLoadInput() diagnostics = %#v", resolveDiagnostics)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestPostDecodeOverrideContextOrdersDecodeCLIAndResolutionEvidence(t *testin
|
|||||||
resolution.RecordNormalization(Keys("api_key"), "select effective secret reference")
|
resolution.RecordNormalization(Keys("api_key"), "select effective secret reference")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution)
|
resolved := runResolution(&configuration, resolver, schema, prepared.decoded, prepared.referenceInput, prepared.resolution, sequencePolicy{})
|
||||||
if len(resolved.diagnostics) != 0 || configuration.Count != 5 || configuration.Derived != 42 {
|
if len(resolved.diagnostics) != 0 || configuration.Count != 5 || configuration.Derived != 42 {
|
||||||
t.Fatalf("resolution = %#v, configuration = %#v", resolved, configuration)
|
t.Fatalf("resolution = %#v, configuration = %#v", resolved, configuration)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ type resolutionSnapshot struct {
|
|||||||
change Change
|
change Change
|
||||||
before *document.Node
|
before *document.Node
|
||||||
after *document.Node
|
after *document.Node
|
||||||
|
entryPath Path
|
||||||
|
hasEntry bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type resolutionResult struct {
|
type resolutionResult struct {
|
||||||
@@ -69,6 +71,7 @@ type resolutionResult struct {
|
|||||||
deltas []documentDelta
|
deltas []documentDelta
|
||||||
snapshots []resolutionSnapshot
|
snapshots []resolutionSnapshot
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
|
correspondence resolutionCorrespondence
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExplicit reports whether surviving direct or inherited input defined path.
|
// IsExplicit reports whether surviving direct or inherited input defined path.
|
||||||
@@ -289,7 +292,7 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
|
|||||||
state.diagnostics = append(state.diagnostics, diagnostic)
|
state.diagnostics = append(state.diagnostics, diagnostic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
|
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput, policy sequencePolicy) resolutionResult {
|
||||||
state := &resolutionState{
|
state := &resolutionState{
|
||||||
active: true, schema: schema, document: input,
|
active: true, schema: schema, document: input,
|
||||||
input: entry,
|
input: entry,
|
||||||
@@ -313,7 +316,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
}
|
}
|
||||||
deltas := compareConfigurationValues(decoded.final, after)
|
correspondence := buildResolutionCorrespondence(decoded.final, after, policy)
|
||||||
|
deltas := compareResolutionValues(decoded.final, after, correspondence)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
coverage := newResolutionCoverageIndexFromRecords(ordered)
|
coverage := newResolutionCoverageIndexFromRecords(ordered)
|
||||||
beforeLookup := buildDocumentLookup(decoded.final)
|
beforeLookup := buildDocumentLookup(decoded.final)
|
||||||
@@ -324,7 +328,9 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
_, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
|
beforePath, hasEntry := correspondence.entryPath(record.path)
|
||||||
|
_, beforePresent := documentAtPathWithLookup(decoded.final, beforePath, beforeLookup)
|
||||||
|
beforePresent = beforePresent && hasEntry
|
||||||
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
|
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
|
||||||
if !beforePresent && !afterPresent {
|
if !beforePresent && !afterPresent {
|
||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
@@ -340,7 +346,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
|
|
||||||
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
|
beforePath, hasEntry := correspondence.entryPath(record.path)
|
||||||
|
var beforeNode *document.Node
|
||||||
|
if hasEntry {
|
||||||
|
beforeNode, _ = documentAtPathWithLookup(decoded.final, beforePath, beforeLookup)
|
||||||
|
}
|
||||||
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
|
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
|
||||||
snapshots = append(snapshots, resolutionSnapshot{
|
snapshots = append(snapshots, resolutionSnapshot{
|
||||||
change: Change{
|
change: Change{
|
||||||
@@ -349,11 +359,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
Reasons: append([]string(nil), record.reasons...),
|
Reasons: append([]string(nil), record.reasons...),
|
||||||
Dependencies: clonePaths(record.dependencies),
|
Dependencies: clonePaths(record.dependencies),
|
||||||
},
|
},
|
||||||
before: document.Clone(beforeNode), after: document.Clone(afterNode),
|
before: document.Clone(beforeNode), after: document.Clone(afterNode), entryPath: beforePath, hasEntry: hasEntry,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return resolutionResult{
|
return resolutionResult{
|
||||||
final: after, deltas: deltas, snapshots: snapshots,
|
final: after, deltas: deltas, snapshots: snapshots, correspondence: correspondence,
|
||||||
diagnostics: canonicalDiagnostics(state.diagnostics),
|
diagnostics: canonicalDiagnostics(state.diagnostics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
internal/engine/resolution_compare.go
Normal file
60
internal/engine/resolution_compare.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/configkit/internal/document"
|
||||||
|
|
||||||
|
// compareResolutionValues reports field-level changes at stable list positions.
|
||||||
|
func compareResolutionValues(before, after *document.Node, correspondence ...resolutionCorrespondence) []documentDelta {
|
||||||
|
var deltas []documentDelta
|
||||||
|
var compare func(*document.Node, *document.Node, Path)
|
||||||
|
compare = func(left, right *document.Node, path Path) {
|
||||||
|
if left == nil || right == nil || left.Kind != right.Kind {
|
||||||
|
compareConfigurationValue(left, right, path, &deltas)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch left.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
leftEntries := make(map[string]*document.Node, len(left.Mapping))
|
||||||
|
for _, entry := range left.Mapping {
|
||||||
|
leftEntries[entry.Key] = entry.Value
|
||||||
|
}
|
||||||
|
for _, entry := range right.Mapping {
|
||||||
|
compare(leftEntries[entry.Key], entry.Value, path.Key(entry.Key))
|
||||||
|
delete(leftEntries, entry.Key)
|
||||||
|
}
|
||||||
|
for key, value := range leftEntries {
|
||||||
|
compare(value, nil, path.Key(key))
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
if len(correspondence) != 0 {
|
||||||
|
if collection, registered := correspondence[0].collections[path.String()]; registered {
|
||||||
|
if !collection.eligible {
|
||||||
|
if !configurationValuesEqual(left, right) {
|
||||||
|
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if collection.structural {
|
||||||
|
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
}
|
||||||
|
for finalIndex, entryIndex := range collection.finalToEntry {
|
||||||
|
if entryIndex >= 0 {
|
||||||
|
compare(left.Sequence[entryIndex], right.Sequence[finalIndex], path.Index(finalIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(left.Sequence) != len(right.Sequence) {
|
||||||
|
deltas = append(deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index := range left.Sequence {
|
||||||
|
compare(left.Sequence[index], right.Sequence[index], path.Index(index))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
compareConfigurationValue(left, right, path, &deltas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compare(before, after, Path{})
|
||||||
|
return sortDocumentDeltas(deltas)
|
||||||
|
}
|
||||||
711
internal/engine/resolution_compare_test.go
Normal file
711
internal/engine/resolution_compare_test.go
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolutionAuditsOrdinaryListFields(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Nested []*int `yaml:"nested"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
other, resize bool
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{name: "covered field"},
|
||||||
|
{name: "uncovered field", other: true, wantPath: "/items/0/enabled"},
|
||||||
|
{name: "length change", resize: true, wantPath: "/items"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := testLoad(Bytes("items", []byte("items: [{count: 3, enabled: true, nested: [null]}]\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0].Count = 0
|
||||||
|
r.RecordNormalization(Keys("items").Index(0).Key("count"), "clear count")
|
||||||
|
if test.other {
|
||||||
|
value.Items[0].Enabled = false
|
||||||
|
}
|
||||||
|
if test.resize {
|
||||||
|
value.Items = append(value.Items, item{})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if test.wantPath == "" {
|
||||||
|
if err != nil || result.Config.Items[0].Count != 0 {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeResolveUnrecordedChange && diagnostic.Path.String() == test.wantPath {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionComparisonKeepsDiffAtomic(t *testing.T) {
|
||||||
|
before := parseCompositionDocument(t, "before", "items: [{values: [true, null]}]")
|
||||||
|
after := parseCompositionDocument(t, "after", "items: [{values: [false, 0]}]")
|
||||||
|
deltas := compareResolutionValues(before, after)
|
||||||
|
if len(deltas) != 2 || deltas[0].path.String() != "/items/0/values/0" || deltas[1].path.String() != "/items/0/values/1" {
|
||||||
|
t.Fatalf("resolution deltas = %#v", deltas)
|
||||||
|
}
|
||||||
|
atomic := compareConfigurationValues(before, after)
|
||||||
|
if len(atomic) != 1 || atomic[0].path.String() != "/items" {
|
||||||
|
t.Fatalf("atomic deltas = %#v", atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionAuditsRegisteredCollectionChanges(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
move, container, rename bool
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{name: "field edit"},
|
||||||
|
{name: "uncovered move", move: true, wantPath: "/items"},
|
||||||
|
{name: "covered move", move: true, container: true},
|
||||||
|
{name: "identity replacement", rename: true, wantPath: "/items"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
definition := Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0].Value = "changed"
|
||||||
|
if test.move {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
}
|
||||||
|
if test.rename {
|
||||||
|
value.Items[0].ID = "new"
|
||||||
|
}
|
||||||
|
index := 0
|
||||||
|
if test.move {
|
||||||
|
index = 1
|
||||||
|
}
|
||||||
|
r.RecordNormalization(Keys("items").Index(index).Key("value"), "change value")
|
||||||
|
if test.container {
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
definition = DefinitionWithSequenceIdentityRegistrations(definition, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("items", []byte("items: [{id: a, value: old}, {id: b, value: other}]\n")), Options[config]{Definition: definition})
|
||||||
|
if test.wantPath == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.move {
|
||||||
|
correspondence := result.history.resolutionPayload.correspondence
|
||||||
|
entry, ok := correspondence.entryPath(Keys("items").Index(1).Key("value"))
|
||||||
|
if !ok || entry.String() != "/items/0/value" {
|
||||||
|
t.Fatalf("retained entry = %s, %t", entry.String(), ok)
|
||||||
|
}
|
||||||
|
for _, snapshot := range result.history.resolutionPayload.snapshots {
|
||||||
|
if snapshot.change.Path.String() == "/items/1/value" && (snapshot.before == nil || snapshot.before.Text != "old" || !snapshot.hasEntry || snapshot.entryPath.String() != "/items/0/value") {
|
||||||
|
t.Fatalf("paired before = %#v", snapshot.before)
|
||||||
|
}
|
||||||
|
if snapshot.before != nil {
|
||||||
|
found := false
|
||||||
|
for _, root := range result.history.documentRoots() {
|
||||||
|
if root == snapshot.before {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("paired snapshot omitted from retained roots")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeResolveUnrecordedChange && diagnostic.Path.String() == test.wantPath {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionFallbackAndHelperTarget(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
registration := []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}}
|
||||||
|
makeDefinition := func(resolve func(*config, *Resolution) error) Definition[config] {
|
||||||
|
return DefinitionWithSequenceIdentityRegistrations(Definition[config]{Defaults: func() config { return config{Items: []item{{ID: "a"}, {ID: "a"}}} }, Resolve: resolve}, registration)
|
||||||
|
}
|
||||||
|
_, err := testLoad(NoSource(), Options[config]{Definition: makeDefinition(func(value *config, r *Resolution) error {
|
||||||
|
value.Items[1].ID = "b"
|
||||||
|
r.RecordNormalization(Keys("items"), "repair identities")
|
||||||
|
return nil
|
||||||
|
})})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repair rejected: %v", err)
|
||||||
|
}
|
||||||
|
_, err = testLoad(NoSource(), Options[config]{Definition: makeDefinition(func(*config, *Resolution) error { return nil })})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid final identities accepted")
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
Normalize(r, Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize")
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}, registration)
|
||||||
|
_, err = testLoad(Bytes("items", []byte("items: [{id: a, value: first}, {id: b, value: second}]\n")), Options[config]{Definition: definition})
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("helper error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Code == CodeResolveInvalidTarget && diagnostic.Path.String() == "/items/0/value" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionMetadataFollowsEntryIdentity(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
entryFixed := false
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
entryFixed = r.IsExplicit(Keys("items").Index(0).Key("enabled")) && !r.IsExplicit(Keys("items").Index(1).Key("enabled"))
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Items = append(value.Items, item{ID: "new"})
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder and add")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, enabled: false}, {id: b, count: 0}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil || !entryFixed {
|
||||||
|
t.Fatalf("load = %#v, fixed entry = %t, error = %v", result, entryFixed, err)
|
||||||
|
}
|
||||||
|
for _, check := range []struct {
|
||||||
|
index int
|
||||||
|
key string
|
||||||
|
explicit bool
|
||||||
|
}{{0, "enabled", false}, {0, "count", true}, {1, "enabled", true}, {1, "count", false}, {2, "id", false}} {
|
||||||
|
info, ok := result.Metadata().Lookup(Keys("items").Index(check.index).Key(check.key))
|
||||||
|
if !ok || info.Explicit != check.explicit || info.Origin.Layer != LayerResolution {
|
||||||
|
t.Errorf("item %d %s = %#v, %t", check.index, check.key, info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionValidationLocationFollowsMovedRecord(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(_ *config, v *Validation) { v.Error(Keys("items").Index(0).Key("value"), "invalid") },
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
_, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition})
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range failure.Diagnostics() {
|
||||||
|
if diagnostic.Path.String() == "/items/0/value" && diagnostic.Primary != nil && diagnostic.Primary.Line == 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionHistoryFollowsMatchedSource(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Items = append(value.Items, item{ID: "new", Value: "added"})
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder and add")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items:\n - id: a\n value: first\n - id: b\n value: second\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, check := range []struct{ index, line int }{{0, 5}, {1, 3}} {
|
||||||
|
path := Keys("items").Index(check.index).Key("value")
|
||||||
|
if location, ok := result.history.latestHistoricalLocation(path); !ok || location.Line != check.line {
|
||||||
|
t.Errorf("historical location for %s = %#v, %t", path.String(), location, ok)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(path)
|
||||||
|
found := false
|
||||||
|
for _, change := range history {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
if location.Line == check.line {
|
||||||
|
found = true
|
||||||
|
} else if location.Line == 3 || location.Line == 5 {
|
||||||
|
t.Errorf("wrong record in %s: %#v", path.String(), history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing source line %d in %#v", check.line, history)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing explanation for %s", path.String())
|
||||||
|
}
|
||||||
|
want := "second\n"
|
||||||
|
if check.index == 1 {
|
||||||
|
want = "first\n"
|
||||||
|
}
|
||||||
|
foundValue := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerMain && change.After.YAML == want {
|
||||||
|
foundValue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundValue {
|
||||||
|
t.Errorf("missing matched input %q in %#v", want, explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, change := range result.Metadata().History(Keys("items").Index(2).Key("value")) {
|
||||||
|
if change.Origin.Layer == LayerMain {
|
||||||
|
t.Fatalf("added record inherited input: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedRecordExplanationIncludesInheritedValue(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: parent, value: inherited}, {id: child, extends: {id: parent}}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.After.YAML == "inherited\n" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("inherited history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedRecordExplanationIncludesCLIValue(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(NoSource(), Options[config]{Definition: definition, Overrides: []Override{{
|
||||||
|
Path: Keys("items"), Flag: "--items", Value: []any{map[string]any{"id": "a", "value": "first"}, map[string]any{"id": "b", "value": "second"}},
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerCLI && change.After.YAML == "first\n" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("CLI history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionExplainsRemovalAndReusedPositions(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items = value.Items[1:]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove first")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := Keys("items").Index(0).Key("value")
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("survivor explanation missing")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
if change.Before.YAML != "invoice\n" || change.After.YAML != "invoice\n" {
|
||||||
|
t.Fatalf("survivor operation = %#v", change)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerMain && change.After.YAML == "report\n" {
|
||||||
|
t.Fatalf("removed occupant leaked into survivor: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing collection operation")
|
||||||
|
}
|
||||||
|
container, ok := result.Inspection.Explain(Keys("items"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("collection explanation missing")
|
||||||
|
}
|
||||||
|
found = false
|
||||||
|
for _, change := range container.History {
|
||||||
|
if change.Origin.Layer == LayerResolution && strings.Contains(change.Before.YAML, "reports") && !strings.Contains(change.After.YAML, "reports") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing removed collection evidence: %#v", container.History)
|
||||||
|
}
|
||||||
|
mainEvidence := false
|
||||||
|
for _, change := range result.Metadata().History(Keys("items")) {
|
||||||
|
if change.Origin.Layer == LayerMain && len(change.Origin.Locations) != 0 {
|
||||||
|
mainEvidence = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mainEvidence {
|
||||||
|
t.Fatal("collection history lost source attribution")
|
||||||
|
}
|
||||||
|
if _, ok := result.Inspection.Explain(Keys("items").Index(1)); !ok {
|
||||||
|
t.Fatal("absent historical position missing")
|
||||||
|
}
|
||||||
|
if _, ok := result.Inspection.Explain(Keys("items").Index(8)); ok {
|
||||||
|
t.Fatal("unknown position reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionExplainsAddedAndChangedIdentities(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
for _, replace := range []bool{false, true} {
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
if replace {
|
||||||
|
value.Items[0] = item{ID: "new", Value: "added"}
|
||||||
|
} else {
|
||||||
|
value.Items = append([]item{{ID: "new", Value: "added"}}, value.Items...)
|
||||||
|
}
|
||||||
|
r.RecordNormalization(Keys("items"), "update collection")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: old, value: original}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("added explanation missing")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerMain || (change.Origin.Layer == LayerResolution && change.Before.Present) {
|
||||||
|
t.Fatalf("added record inherited previous value: %#v", explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replace {
|
||||||
|
moved, ok := result.Inspection.Explain(Keys("items").Index(1).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("shifted survivor missing")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, change := range moved.History {
|
||||||
|
if change.Origin.Layer == LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "original\n" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("shifted survivor = %#v", moved.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionExplainsRemovedFieldOnMovedRecord(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{Resolve: func(value *config, r *Resolution) error {
|
||||||
|
delete(value.Items[0], "extra")
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove and reorder")
|
||||||
|
return nil
|
||||||
|
}}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b, extra: other}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(1).Key("extra"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("removed field explanation missing")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.Origin.Layer == LayerResolution && change.Before.YAML == "own\n" && !change.After.Present {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("removed field history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionTransfersSensitivityFromEntry(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Derived = value.Items[1].Value
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items").Index(0).Key("value"), Keys("items").Index(1).Key("value"), Keys("derived")} {
|
||||||
|
if !pathIsSecret(result.Inspection.secrets, path) {
|
||||||
|
t.Errorf("sensitivity lost at %s", path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredResolutionKeepsRemovedSensitivity(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/value" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items = value.Items[1:]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove record")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: confidential}, {id: b, value: visible}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !pathIsSecret(result.Inspection.secrets, Keys("items").Index(0).Key("value")) {
|
||||||
|
t.Fatal("removed historical sensitivity lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedAndRemovedSensitiveValuesStayHidden(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
for _, remove := range []bool{false, true} {
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/1/value" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Derived = value.Items[1].Value
|
||||||
|
r.RecordNormalization(Keys("items").Index(1).Key("value"), "retain value", Keys("derived"))
|
||||||
|
if remove {
|
||||||
|
value.Items = value.Items[:1]
|
||||||
|
}
|
||||||
|
r.RecordNormalization(Keys("items"), "update records")
|
||||||
|
r.RecordNormalization(Keys("derived"), "derive", Keys("items").Index(1).Key("value"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, value: PRIVATE_CANARY}, {id: b, value: other}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items"), Keys("items").Index(0), Keys("items").Index(1), Keys("derived")} {
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("effective leaked at %s", path.String())
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "PRIVATE_CANARY") || strings.Contains(change.After.YAML, "PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("history leaked at %s: %#v", path.String(), change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := result.Inspection.WriteEffective(&output); err != nil || strings.Contains(output.String(), "PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("effective output leaked: %q, %v", output.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedDynamicTaggedValueStaysHidden(t *testing.T) {
|
||||||
|
type sensitive struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Token string `yaml:"token" configkit:"secret"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []any `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Defaults: func() config {
|
||||||
|
return config{Items: []any{sensitive{ID: "a", Token: "DYNAMIC_PRIVATE_CANARY"}, sensitive{ID: "b", Token: "other"}}}
|
||||||
|
},
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(NoSource(), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items"), Keys("items").Index(1).Key("token")} {
|
||||||
|
explanation, ok := result.Inspection.Explain(path)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing explanation %s", path.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "DYNAMIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("effective value leaked")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "DYNAMIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "DYNAMIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("history leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedDependencyProtectsEarlierMatchedValue(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/secret" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
value.Items[0].Value = value.Items[1].Secret
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
r.RecordNormalization(Keys("items").Index(0).Key("value"), "derive", Keys("items").Index(1).Key("secret"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, secret: HIDDEN_A, value: public}, {id: b, value: EARLIER_PRIVATE}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Inspection.Explain(Keys("items").Index(0).Key("value"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "EARLIER_PRIVATE") || strings.Contains(change.After.YAML, "HIDDEN_A") {
|
||||||
|
t.Fatalf("sensitive lineage leaked: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/engine/resolution_correspondence.go
Normal file
165
internal/engine/resolution_correspondence.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/configkit/internal/document"
|
||||||
|
|
||||||
|
// collectionCorrespondence describes matching at one concrete registered path.
|
||||||
|
// A negative index denotes an identity present only at the other endpoint.
|
||||||
|
type collectionCorrespondence struct {
|
||||||
|
path Path
|
||||||
|
eligible bool
|
||||||
|
structural bool
|
||||||
|
entryToFinal []int
|
||||||
|
finalToEntry []int
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolutionCorrespondence struct {
|
||||||
|
collections map[string]collectionCorrespondence
|
||||||
|
prefix pathPrefixIndex
|
||||||
|
indexedCollections []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c resolutionCorrespondence) clone() resolutionCorrespondence {
|
||||||
|
var result resolutionCorrespondence
|
||||||
|
for _, collection := range c.collections {
|
||||||
|
collection.path = collection.path.clone()
|
||||||
|
collection.entryToFinal = append([]int(nil), collection.entryToFinal...)
|
||||||
|
collection.finalToEntry = append([]int(nil), collection.finalToEntry...)
|
||||||
|
result.indexCollection(collection)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *resolutionCorrespondence) indexCollection(collection collectionCorrespondence) {
|
||||||
|
if c.collections == nil {
|
||||||
|
c.collections = make(map[string]collectionCorrespondence)
|
||||||
|
}
|
||||||
|
pointer := collection.path.String()
|
||||||
|
c.collections[pointer] = collection
|
||||||
|
if collection.eligible {
|
||||||
|
c.prefix.insert(pointer, len(c.indexedCollections))
|
||||||
|
c.indexedCollections = append(c.indexedCollections, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildResolutionCorrespondence(entry, final *document.Node, policy sequencePolicy) resolutionCorrespondence {
|
||||||
|
var result resolutionCorrespondence
|
||||||
|
if policy.selectorTrie == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var indexes mappingIndex
|
||||||
|
var visit func(*document.Node, *document.Node, Path)
|
||||||
|
visit = func(before, after *document.Node, path Path) {
|
||||||
|
if fields, registered := policy.identityFieldsAt(path); registered &&
|
||||||
|
((before != nil && before.Kind == document.Sequence) || (after != nil && after.Kind == document.Sequence)) {
|
||||||
|
result.indexCollection(matchResolutionCollection(before, after, path, fields))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if before != nil && before.Kind == document.Mapping {
|
||||||
|
for _, item := range before.Mapping {
|
||||||
|
var next *document.Node
|
||||||
|
if after != nil && after.Kind == document.Mapping {
|
||||||
|
if position := indexes.find(after, item.Key); position >= 0 {
|
||||||
|
next = after.Mapping[position].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(item.Value, next, path.Key(item.Key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after != nil && after.Kind == document.Mapping {
|
||||||
|
for _, item := range after.Mapping {
|
||||||
|
if before == nil || before.Kind != document.Mapping || indexes.find(before, item.Key) < 0 {
|
||||||
|
visit(nil, item.Value, path.Key(item.Key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(entry, final, Path{})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchResolutionCollection(before, after *document.Node, path Path, fields []string) collectionCorrespondence {
|
||||||
|
result := collectionCorrespondence{path: path.clone()}
|
||||||
|
prior, priorIssues := validateNormalizedSequenceIdentityFrom(before, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
|
||||||
|
later, laterIssues := validateNormalizedSequenceIdentityFrom(after, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
|
||||||
|
if len(priorIssues) != 0 || len(laterIssues) != 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.eligible = true
|
||||||
|
result.entryToFinal = make([]int, len(before.Sequence))
|
||||||
|
result.finalToEntry = make([]int, len(after.Sequence))
|
||||||
|
for i := range result.entryToFinal {
|
||||||
|
result.entryToFinal[i] = -1
|
||||||
|
}
|
||||||
|
for i := range result.finalToEntry {
|
||||||
|
result.finalToEntry[i] = -1
|
||||||
|
}
|
||||||
|
result.structural = len(prior) != len(later)
|
||||||
|
for tuple, previous := range prior {
|
||||||
|
if current, found := later[tuple]; found {
|
||||||
|
result.entryToFinal[previous.index] = current.index
|
||||||
|
result.finalToEntry[current.index] = previous.index
|
||||||
|
if previous.index != current.index {
|
||||||
|
result.structural = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.structural = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryPath translates a surviving final record prefix; additions have no entry.
|
||||||
|
// Paths outside eligible collections and wholly absent final positions stay positional.
|
||||||
|
func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) {
|
||||||
|
return c.translate(path, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalPath translates historical record descendants without matching removals.
|
||||||
|
func (c resolutionCorrespondence) finalPath(path Path) (Path, bool) {
|
||||||
|
return c.translate(path, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c resolutionCorrespondence) translate(path Path, toFinal bool) (Path, bool) {
|
||||||
|
if !path.valid() {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
translated := path.clone()
|
||||||
|
if len(c.indexedCollections) == 0 {
|
||||||
|
return translated, true
|
||||||
|
}
|
||||||
|
matched, handled := true, false
|
||||||
|
c.prefix.visitAncestors(path.String(), func(id int) {
|
||||||
|
if handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collection := c.collections[c.indexedCollections[id]]
|
||||||
|
depth := len(collection.path.segments)
|
||||||
|
if depth >= len(path.segments) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, segment := range collection.path.segments {
|
||||||
|
if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index, ok := schemaIndex(path.segments[depth])
|
||||||
|
indices := collection.finalToEntry
|
||||||
|
if toFinal {
|
||||||
|
indices = collection.entryToFinal
|
||||||
|
}
|
||||||
|
if !ok || index >= len(indices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
current := indices[index]
|
||||||
|
if current < 0 {
|
||||||
|
matched = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: current}
|
||||||
|
})
|
||||||
|
if !matched {
|
||||||
|
return Path{}, false
|
||||||
|
}
|
||||||
|
return translated, true
|
||||||
|
}
|
||||||
73
internal/engine/resolution_correspondence_test.go
Normal file
73
internal/engine/resolution_correspondence_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolutionCollectionCorrespondence(t *testing.T) {
|
||||||
|
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||||
|
}})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, before, after string
|
||||||
|
eligible, structural bool
|
||||||
|
finalIndex, entryIndex int
|
||||||
|
matched bool
|
||||||
|
}{
|
||||||
|
{"swap", "items: [{id: a}, {id: b}]", "items: [{id: b}, {id: a}]", true, true, 0, 1, true},
|
||||||
|
{"insert", "items: [{id: a}]", "items: [{id: b}, {id: a}]", true, true, 0, 0, false},
|
||||||
|
{"remove", "items: [{id: a}, {id: b}]", "items: [{id: b}]", true, true, 0, 1, true},
|
||||||
|
{"same", "items: [{id: a}]", "items: [{id: a}]", true, false, 0, 0, true},
|
||||||
|
{"duplicate", "items: [{id: a}, {id: a}]", "items: [{id: a}]", false, false, 0, 0, true},
|
||||||
|
{"missing", "items: [{name: a}]", "items: [{id: a}]", false, false, 0, 0, true},
|
||||||
|
{"identity replacement", "items: [{id: a}]", "items: [{id: b}]", true, true, 0, 0, false},
|
||||||
|
{"invalid final", "items: [{id: a}]", "items: [bad]", false, false, 0, 0, true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
before := parseCompositionDocument(t, "before", test.before)
|
||||||
|
after := parseCompositionDocument(t, "after", test.after)
|
||||||
|
matching := buildResolutionCorrespondence(before, after, policy)
|
||||||
|
collection := matching.collections["/items"]
|
||||||
|
if collection.eligible != test.eligible || collection.structural != test.structural {
|
||||||
|
t.Fatalf("collection = %#v", collection)
|
||||||
|
}
|
||||||
|
entry, ok := matching.entryPath(Keys("items").Index(test.finalIndex).Key("value"))
|
||||||
|
if ok != test.matched || (ok && entry.String() != Keys("items").Index(test.entryIndex).Key("value").String()) {
|
||||||
|
t.Fatalf("entry = %s, matched = %t", entry.String(), ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionCorrespondenceDynamicCompoundIdentities(t *testing.T) {
|
||||||
|
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{literalSequenceSelector("groups"), anyMappingSequenceSelector(), literalSequenceSelector("items")},
|
||||||
|
identityFields: []string{"id", "kind"},
|
||||||
|
}})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
before := parseCompositionDocument(t, "before", "groups: {alpha: {items: [{id: 1, kind: x}, {id: '1', kind: x}]}, empty: {items: []}}")
|
||||||
|
after := parseCompositionDocument(t, "after", "groups: {alpha: {items: [{id: '1', kind: x}, {id: 1.0, kind: x}]}, empty: {items: []}}")
|
||||||
|
matching := buildResolutionCorrespondence(before, after, policy)
|
||||||
|
retained := matching.clone()
|
||||||
|
changed := matching.collections["/groups/alpha/items"]
|
||||||
|
changed.finalToEntry[0] = 0
|
||||||
|
matching.collections["/groups/alpha/items"] = changed
|
||||||
|
if entry, ok := retained.entryPath(Keys("groups", "alpha", "items").Index(0)); !ok || entry.String() != "/groups/alpha/items/1" {
|
||||||
|
t.Fatalf("retained correspondence shares mutable indexes: %s, %t", entry.String(), ok)
|
||||||
|
}
|
||||||
|
matching = retained
|
||||||
|
if empty := matching.collections["/groups/empty/items"]; !empty.eligible || empty.structural {
|
||||||
|
t.Fatalf("empty = %#v", empty)
|
||||||
|
}
|
||||||
|
path, ok := matching.entryPath(Keys("groups", "alpha", "items").Index(0).Key("missing"))
|
||||||
|
if !ok || path.String() != "/groups/alpha/items/1/missing" {
|
||||||
|
t.Fatalf("entry = %s, %t", path.String(), ok)
|
||||||
|
}
|
||||||
|
wrongShape, ok := matching.entryPath(Keys("groups").Index(0).Key("items").Index(0))
|
||||||
|
if !ok || wrongShape.String() != "/groups/0/items/0" {
|
||||||
|
t.Fatalf("mapping shape = %s, %t", wrongShape.String(), ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
187
internal/engine/resolution_remediation_test.go
Normal file
187
internal/engine/resolution_remediation_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMovedRemovedFieldHasConsistentHistory(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
delete(value.Items[0], "extra")
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "remove field and reorder")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: own}, {id: b}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := Keys("items").Index(1).Key("extra")
|
||||||
|
if _, found := result.Metadata().Lookup(path); found {
|
||||||
|
t.Fatal("removed field is effective")
|
||||||
|
}
|
||||||
|
explanation, found := result.Inspection.Explain(path)
|
||||||
|
if !found || len(explanation.History) != 2 {
|
||||||
|
t.Fatalf("explanation = %#v, %t", explanation, found)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(path)
|
||||||
|
if len(history) != 2 || history[0].Origin.Layer != LayerMain || history[1].Origin.Operation != ChangeNormalization {
|
||||||
|
t.Fatalf("metadata history = %#v", history)
|
||||||
|
}
|
||||||
|
last := explanation.History[1]
|
||||||
|
if last.Before.YAML != "own\n" || last.After.Present {
|
||||||
|
t.Fatalf("removal = %#v", last)
|
||||||
|
}
|
||||||
|
for _, unknown := range []Path{Keys("items", "1", "extra"), Keys("items").Index(1).Key("unknown")} {
|
||||||
|
if history := result.Metadata().History(unknown); len(history) != 0 {
|
||||||
|
t.Fatalf("unexpected history for %s: %#v", unknown.String(), history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovedInheritedHistoryUsesFinalRecordPrefix(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
}
|
||||||
|
field := Keys("items").Index(0).Key("nested").Key("more").Key("value")
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Items[0], value.Items[1] = value.Items[1], value.Items[0]
|
||||||
|
r.RecordNormalization(Keys("items"), "reorder")
|
||||||
|
r.RecordNormalization(field, "retain inherited field")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items:\n - id: parent\n nested: {more: {value: old}}\n - id: child\n extends: {id: parent}\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
physical := result.Metadata().History(Keys("items"))
|
||||||
|
sourceID := result.Inspection.Sources()[0].ID
|
||||||
|
for _, query := range []Path{field, Keys("items").Index(0)} {
|
||||||
|
inherited, recorded := false, false
|
||||||
|
for _, change := range result.Metadata().History(query) {
|
||||||
|
if change.Origin.Operation == ChangeInheritance {
|
||||||
|
inherited = true
|
||||||
|
if !pathContains(Keys("items").Index(0), change.Path) {
|
||||||
|
t.Errorf("inheritance path for %s = %s", query.String(), change.Path.String())
|
||||||
|
}
|
||||||
|
if query.String() == field.String() && change.Path.String() != "/items/0" {
|
||||||
|
t.Errorf("container operation replaced by leaf path: %s", change.Path.String())
|
||||||
|
}
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
if location.SourceID != sourceID || (query.String() == field.String() && location.Line != 3) {
|
||||||
|
t.Errorf("source coordinates changed: %#v", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerResolution && len(change.Reasons) == 1 && change.Reasons[0] == "retain inherited field" {
|
||||||
|
recorded = true
|
||||||
|
if change.Path.String() != field.String() {
|
||||||
|
t.Errorf("final resolution path translated twice: %s", change.Path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !inherited || !recorded {
|
||||||
|
t.Errorf("missing inherited or recorded evidence for %s", query.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, change := range physical {
|
||||||
|
if change.Origin.Operation == ChangeInheritance && !pathContains(Keys("items").Index(1), change.Path) {
|
||||||
|
t.Errorf("collection history lost physical coordinates: %s", change.Path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCyclicMatchingProtectsRemovedAndDerivedValues(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Items []map[string]any `yaml:"items"`
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
}
|
||||||
|
removed := Keys("items").Index(1).Key("extra")
|
||||||
|
definition := DefinitionWithSequenceIdentityRegistrations(Definition[config]{
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/items/0/extra" },
|
||||||
|
Resolve: func(value *config, r *Resolution) error {
|
||||||
|
value.Derived = value.Items[0]["extra"].(string)
|
||||||
|
delete(value.Items[0], "extra")
|
||||||
|
last := value.Items[len(value.Items)-1]
|
||||||
|
copy(value.Items[1:], value.Items[:len(value.Items)-1])
|
||||||
|
value.Items[0] = last
|
||||||
|
r.RecordNormalization(Keys("items"), "remove field and rotate")
|
||||||
|
r.RecordDerivedDefault(Keys("derived"), "derive from removed value", removed)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items")), IdentityFields: []string{"id"}}})
|
||||||
|
result, err := testLoad(Bytes("records", []byte("items: [{id: a, extra: CYCLIC_PRIVATE_CANARY}, {id: b}, {id: c}, {id: d}]\n")), Options[config]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Keys("items"), removed, Keys("derived")} {
|
||||||
|
explanation, found := result.Inspection.Explain(path)
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing explanation for %s", path.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.Effective.YAML, "CYCLIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("effective value leaked at %s", path.String())
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if strings.Contains(change.Before.YAML, "CYCLIC_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "CYCLIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatalf("history leaked at %s", path.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := result.Inspection.WriteEffective(&output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(output.String(), "CYCLIC_PRIVATE_CANARY") {
|
||||||
|
t.Fatal("effective rendering leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrespondenceIndexPreservesPathShapes(t *testing.T) {
|
||||||
|
destination := Keys("groups", "0", "a/b~")
|
||||||
|
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||||
|
selector: sequenceIdentitySelector{
|
||||||
|
literalSequenceSelector("groups"), literalSequenceSelector("0"), literalSequenceSelector("a/b~"),
|
||||||
|
},
|
||||||
|
identityFields: []string{"id"},
|
||||||
|
}})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
before := parseCompositionDocument(t, "before", "groups: {'0': {'a/b~': [{id: a}, {id: b}]}}")
|
||||||
|
after := parseCompositionDocument(t, "after", "groups: {'0': {'a/b~': [{id: b}, {id: a}, {id: new}]}}")
|
||||||
|
matching := buildResolutionCorrespondence(before, after, policy)
|
||||||
|
parsed, err := ParsePath("/groups/0/a~1b~0/0/field")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []Path{destination.Index(0).Key("field"), parsed} {
|
||||||
|
entry, found := matching.entryPath(path)
|
||||||
|
if !found || entry.String() != destination.Index(1).Key("field").String() {
|
||||||
|
t.Fatalf("entry = %s, %t", entry.String(), found)
|
||||||
|
}
|
||||||
|
final, found := matching.finalPath(entry)
|
||||||
|
if !found || final.String() != path.String() {
|
||||||
|
t.Fatalf("round trip = %s, %t", final.String(), found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []Path{
|
||||||
|
Keys("groups").Index(0).Key("a/b~").Index(0),
|
||||||
|
destination.Key("0"), destination.Index(8), destination,
|
||||||
|
} {
|
||||||
|
entry, found := matching.entryPath(path)
|
||||||
|
if !found || entry.String() != path.String() {
|
||||||
|
t.Fatalf("positional path %s became %s, %t", path.String(), entry.String(), found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, found := matching.entryPath(destination.Index(2)); found {
|
||||||
|
t.Fatal("new identity acquired entry evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,11 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa
|
|||||||
classifyPath(resolved)
|
classifyPath(resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return closeSecretDependencies(paths, secret, dependencies, inherited)
|
var correspondence resolutionCorrespondence
|
||||||
|
if model := metadata.historyEvidence(); model != nil && model.resolutionPayload != nil {
|
||||||
|
correspondence = model.resolutionPayload.correspondence
|
||||||
|
}
|
||||||
|
return closeSecretDependenciesWithCorrespondence(paths, secret, dependencies, inherited, correspondence)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSecretPath(paths map[string]Path, pointer string) {
|
func addSecretPath(paths map[string]Path, pointer string) {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ type secretTrigger struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
|
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
|
||||||
|
return closeSecretDependenciesWithCorrespondence(paths, sensitive, dependencies, inherited, resolutionCorrespondence{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matching adds endpoint edges to the same work queue as recorded dependencies.
|
||||||
|
// Newly reached coordinates are indexed once, including historical fields that
|
||||||
|
// have no physical descendant at the corresponding final position.
|
||||||
|
func closeSecretDependenciesWithCorrespondence(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency, correspondence resolutionCorrespondence) map[string]bool {
|
||||||
result := make(map[string]bool, len(sensitive))
|
result := make(map[string]bool, len(sensitive))
|
||||||
queue := make([]string, 0, len(sensitive))
|
queue := make([]string, 0, len(sensitive))
|
||||||
for pointer, classified := range sensitive {
|
for pointer, classified := range sensitive {
|
||||||
@@ -17,12 +24,14 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
|||||||
|
|
||||||
var triggers pathPrefixIndex
|
var triggers pathPrefixIndex
|
||||||
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
|
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
|
||||||
|
fired := make([]bool, 0, cap(triggerValues))
|
||||||
insert := func(dependency Path, target string, ordinary bool) {
|
insert := func(dependency Path, target string, ordinary bool) {
|
||||||
if ordinary && !dependency.valid() {
|
if ordinary && !dependency.valid() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := len(triggerValues)
|
id := len(triggerValues)
|
||||||
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
|
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
|
||||||
|
fired = append(fired, false)
|
||||||
triggers.insert(dependency.String(), id)
|
triggers.insert(dependency.String(), id)
|
||||||
}
|
}
|
||||||
for _, dependency := range dependencies {
|
for _, dependency := range dependencies {
|
||||||
@@ -33,7 +42,46 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
|||||||
insert(link.target, link.dependency.String(), false)
|
insert(link.target, link.dependency.String(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fired := make([]bool, len(triggerValues))
|
var projected map[string]bool
|
||||||
|
if len(correspondence.indexedCollections) != 0 {
|
||||||
|
projected = make(map[string]bool, len(paths))
|
||||||
|
}
|
||||||
|
project := func(path Path) {
|
||||||
|
if projected == nil || !path.valid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pointer := path.String()
|
||||||
|
if projected[pointer] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projected[pointer] = true
|
||||||
|
add := func(target Path, matched bool) {
|
||||||
|
if !matched {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetPointer := target.String()
|
||||||
|
if targetPointer == pointer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := paths[targetPointer]; !exists {
|
||||||
|
paths[targetPointer] = target
|
||||||
|
}
|
||||||
|
insert(path, targetPointer, false)
|
||||||
|
}
|
||||||
|
add(correspondence.finalPath(path))
|
||||||
|
add(correspondence.entryPath(path))
|
||||||
|
}
|
||||||
|
if projected != nil {
|
||||||
|
// Projection may add missing target coordinates to paths. Snapshot the
|
||||||
|
// original candidates so map insertion cannot affect this traversal.
|
||||||
|
candidates := make([]Path, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
candidates = append(candidates, path)
|
||||||
|
}
|
||||||
|
for _, path := range candidates {
|
||||||
|
project(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
activate := func(id int) {
|
activate := func(id int) {
|
||||||
if fired[id] {
|
if fired[id] {
|
||||||
return
|
return
|
||||||
@@ -48,6 +96,9 @@ func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, d
|
|||||||
for head := 0; head < len(queue); head++ {
|
for head := 0; head < len(queue); head++ {
|
||||||
pointer := queue[head]
|
pointer := queue[head]
|
||||||
if classified, ok := paths[pointer]; ok && classified.valid() {
|
if classified, ok := paths[pointer]; ok && classified.valid() {
|
||||||
|
// Register a synthesized coordinate before visiting triggers, never
|
||||||
|
// while a trigger-index traversal is in progress.
|
||||||
|
project(classified)
|
||||||
triggers.visitAncestors(classified.String(), func(id int) {
|
triggers.visitAncestors(classified.String(), func(id int) {
|
||||||
if triggerValues[id].ordinary {
|
if triggerValues[id].ordinary {
|
||||||
activate(id)
|
activate(id)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func validOverlayName(name string) bool {
|
|||||||
|
|
||||||
type selectedOverlayRequest struct {
|
type selectedOverlayRequest struct {
|
||||||
name string
|
name string
|
||||||
|
lookupDirectory string
|
||||||
protectionRule *VersionRule
|
protectionRule *VersionRule
|
||||||
sequencePolicy sequencePolicy
|
sequencePolicy sequencePolicy
|
||||||
limits importLimits
|
limits importLimits
|
||||||
@@ -70,13 +71,26 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
|||||||
_ = access.close()
|
_ = access.close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
overlayPath := access.join(access.rootPath, "overlays/"+request.name+".yml")
|
lookupDirectory := request.lookupDirectory
|
||||||
canonical, err := access.canonical(overlayPath)
|
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 {
|
if err != nil {
|
||||||
code := CodeOverlayInvalid
|
code := CodeOverlayInvalid
|
||||||
message := "selected overlay could not be resolved"
|
message := "selected overlay could not be resolved"
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
code, message = CodeOverlayMissing, "selected overlay does not exist"
|
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) {
|
} else if errors.Is(err, fs.ErrPermission) {
|
||||||
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
code, message = CodeImportPermission, "selected overlay cannot be accessed"
|
||||||
}
|
}
|
||||||
@@ -85,13 +99,9 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces
|
|||||||
if !access.contains(canonical) {
|
if !access.contains(canonical) {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayOutsideRoot, "selected overlay resolves outside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
for _, existing := range sources {
|
if overlayRepeatsSource(access, links, canonical, 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)}
|
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayRepeated, "selected overlay is already present in the base source graph", nil)}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if len(sources)+1 > limits.sources {
|
if len(sources)+1 > limits.sources {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeOverlayLimitExceeded, "selected overlay exceeds the source-count limit", nil)}
|
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
|
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) {
|
func readOverlayFile(access *filesystemAccess, name, sourceID string, remaining int64) ([]byte, *Diagnostic) {
|
||||||
limit := min(int64(maxSourceBytes), remaining)
|
limit := min(int64(maxSourceBytes), remaining)
|
||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
|
|||||||
24
layered.go
24
layered.go
@@ -19,15 +19,29 @@ type Input struct {
|
|||||||
PlacementMode PlacementMode
|
PlacementMode PlacementMode
|
||||||
Imports bool
|
Imports bool
|
||||||
Filesystem *FilesystemContext
|
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
|
// 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.
|
// appear in lowest-to-highest precedence order; an empty tier is a no-op.
|
||||||
type Underlay struct{ Inputs []Input }
|
type Underlay struct{ Inputs []Input }
|
||||||
|
|
||||||
// LayeredOptions selects lower-priority tiers and final application rules.
|
// LayeredOptions selects lower-priority tiers and final application rules.
|
||||||
// An active Overlay requires an explicit OverlayFilesystem; an inactive overlay
|
// An active Overlay uses OverlayFilesystem, then the main input's resolved
|
||||||
// ignores it. Overrides remain explicit CLI records, not general mappings.
|
// 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,
|
// SourceVersionMode applies one declaration policy to main, underlay, import,
|
||||||
// and selected overlay documents. Enabled modes interpret the version path
|
// and selected overlay documents. Enabled modes interpret the version path
|
||||||
// source-locally, consume accepted non-main declarations, and protect main's
|
// source-locally, consume accepted non-main declarations, and protect main's
|
||||||
@@ -42,6 +56,8 @@ type LayeredOptions[T any] struct {
|
|||||||
Underlays []Underlay
|
Underlays []Underlay
|
||||||
Overlay string
|
Overlay string
|
||||||
OverlayFilesystem *FilesystemContext
|
OverlayFilesystem *FilesystemContext
|
||||||
|
// OverlayFallback supplies a context if the main has no usable context.
|
||||||
|
OverlayFallback *FilesystemContext
|
||||||
Overrides []Override
|
Overrides []Override
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +84,9 @@ func toEngineInput(input Input) engine.Input {
|
|||||||
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
||||||
PlacementMode: engine.PlacementMode(input.PlacementMode),
|
PlacementMode: engine.PlacementMode(input.PlacementMode),
|
||||||
}
|
}
|
||||||
|
if input.MainRelative != nil {
|
||||||
|
result.MainRelative = &engine.MainRelativeContext{Fallback: toEngineFilesystemContext(input.MainRelative.Fallback), RequireBase: input.MainRelative.RequireBase}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +96,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
|
|||||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
Overlay: options.Overlay,
|
Overlay: options.Overlay,
|
||||||
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
|
||||||
|
OverlayFallback: toEngineFilesystemContext(options.OverlayFallback),
|
||||||
ImportDefaults: toEngineImportDefaults(options.ImportDefaults),
|
ImportDefaults: toEngineImportDefaults(options.ImportDefaults),
|
||||||
}
|
}
|
||||||
if options.Underlays != nil {
|
if options.Underlays != nil {
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ type Override struct {
|
|||||||
|
|
||||||
// Resolution lets a resolver inspect supplied values and record derived defaults
|
// Resolution lets a resolver inspect supplied values and record derived defaults
|
||||||
// or normalization. Its input queries describe the state at callback entry,
|
// or normalization. Its input queries describe the state at callback entry,
|
||||||
// even after the resolver changes the configuration. Use the context only during
|
// even after the resolver changes the configuration. Records and helpers use
|
||||||
|
// final paths; helpers do not follow moved records. Use the context only during
|
||||||
// the callback; do not retain it or use it concurrently.
|
// the callback; do not retain it or use it concurrently.
|
||||||
type Resolution struct{ value *engine.Resolution }
|
type Resolution struct{ value *engine.Resolution }
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ func (m Metadata) IsExplicit(path Path) bool {
|
|||||||
// History returns detached definition, inheritance, and resolution records for path. A record's
|
// History returns detached definition, inheritance, and resolution records for path. A record's
|
||||||
// Path can be an ancestor when that container operation replaced or removed path.
|
// Path can be an ancestor when that container operation replaced or removed path.
|
||||||
// Removed historical paths can have history even though Lookup returns false.
|
// Removed historical paths can have history even though Lookup returns false.
|
||||||
|
// Surviving registered records follow their matched resolution-entry history;
|
||||||
|
// collection queries retain removed evidence at physical positions.
|
||||||
// Unknown and invalid paths return nil.
|
// Unknown and invalid paths return nil.
|
||||||
func (m Metadata) History(path Path) []Change {
|
func (m Metadata) History(path Path) []Change {
|
||||||
values := m.value.History(toEnginePath(path))
|
values := m.value.History(toEnginePath(path))
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git",
|
"expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git",
|
||||||
"validation": {
|
"validation": {
|
||||||
"forbidden_files": [".releasekit/gitea-token"],
|
"forbidden_files": [".releasekit/gitea-token"],
|
||||||
|
"markdown": {},
|
||||||
|
"module_distribution": {
|
||||||
|
"local_replacements": true
|
||||||
|
},
|
||||||
"go": {
|
"go": {
|
||||||
"race": true,
|
"race": true,
|
||||||
"full_vet": true,
|
"full_vet": true,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ func SequenceAnyMappingKey() SequenceSelectorSegment {
|
|||||||
|
|
||||||
// SequenceIdentity records the identity fields used for strict sequence
|
// SequenceIdentity records the identity fields used for strict sequence
|
||||||
// composition, identified-record parent lookup, and final typed-result
|
// composition, identified-record parent lookup, and final typed-result
|
||||||
// validation during loading.
|
// validation during loading. Eligible entry and final records also correspond
|
||||||
|
// during resolution auditing and inspection.
|
||||||
type SequenceIdentity struct {
|
type SequenceIdentity struct {
|
||||||
exactPath bool
|
exactPath bool
|
||||||
path Path
|
path Path
|
||||||
|
|||||||
Reference in New Issue
Block a user