62 Commits

Author SHA1 Message Date
c1c7e87f2c Write ConfigKit 1.2.0 release notes 2026-11-10 14:54:46 +00:00
99098d2974 Defer single-file overlays and retire the roadmap 2026-11-10 14:53:25 +00:00
3e50388b73 Make overlay version checks file-scoped 2026-11-10 14:34:12 +00:00
22603f0e5b Settle the direct overlay catalog format 2026-11-10 02:56:28 +00:00
5f001cf597 Retire completed companion loading roadmaps 2026-11-10 01:26:41 +00:00
47547bc63e Plan single-file overlays with strict peer composition 2026-11-09 21:34:17 +00:00
ce6005ca2d Share resolved companion contexts by tier 2026-11-09 21:22:46 +00:00
ef420c943b Reuse default import patterns 2026-11-09 21:20:48 +00:00
f0198f865d Validate access to derived host roots 2026-11-09 21:20:02 +00:00
367fe4120f Clarify companion binding ownership 2026-11-09 21:19:05 +00:00
37ea670747 Cover search exhaustion and fallback selection 2026-11-09 21:18:49 +00:00
68de431bb9 Share declaring import assembly 2026-11-09 21:18:16 +00:00
720df98a5a Mark native overlay remediation complete 2026-11-09 21:16:43 +00:00
81c27bb9cd Check native overlay link targets 2026-11-09 21:16:30 +00:00
9a08438769 Record main-relative scalability findings 2026-11-09 21:01:36 +00:00
a05bb94a21 Record main-relative integration audit 2026-11-09 20:59:12 +00:00
d2608e8d56 Record main-relative loading review findings 2026-11-09 20:56:01 +00:00
18af2d2130 Document main-relative companion loading 2026-11-09 20:47:44 +00:00
bc3fc91296 Verify main-relative loading and evidence 2026-11-09 20:44:32 +00:00
08ea01921b Demonstrate main-relative companion loading 2026-11-09 20:41:54 +00:00
761dd0a4e0 Derive selected overlay context from the main input 2026-11-09 20:40:40 +00:00
b93cbb0639 Handle companion imports without a filesystem base 2026-11-09 20:38:55 +00:00
e47960d377 Share import declaration preparation 2026-11-09 18:45:51 +00:00
f0a4bf9bb2 Bind companion inputs to the main context 2026-11-09 18:45:00 +00:00
0a136063da Define main-relative input options 2026-11-09 18:42:09 +00:00
ae0c94d3c6 Plan main-relative companion inputs 2026-11-09 18:39:25 +00:00
c51bea9b4f Adopt upstream documentation, OSS, and release policy updates 2026-11-09 18:31:14 +00:00
132b9ec659 Clarify binding scope and fallback behavior in the companion roadmap 2026-11-09 18:08:17 +00:00
c0bc5e2b73 Settle main-source eligibility for relative context binding 2026-11-09 18:00:31 +00:00
daa0cf9e88 Settle containment and symlink behavior for companion inputs 2026-11-09 17:55:27 +00:00
2ba61d5c7c Settle absent-main behavior for companion inputs 2026-11-09 17:49:08 +00:00
e36740313d Settle the main-relative context binding API 2026-11-09 17:46:31 +00:00
1960304f6f Revise the companion input roadmap around main-derived contexts 2026-11-09 17:33:30 +00:00
26ea24958c Plan main-relative underlays and overlays 2026-11-09 17:01:48 +00:00
191015e289 Prepare release notes for ConfigKit 1.1.0 2026-11-03 02:25:47 +00:00
7e53863adb Delegate release file checks to ReleaseKit 2026-11-03 01:32:05 +00:00
f7044cf695 Explain source-aware path normalization inside lists 2026-11-03 01:27:33 +00:00
c2b3cb32da Document field-level resolution and matched sequence history 2026-11-03 01:01:25 +00:00
43ac34d10e Adopt ReleaseKit 0.8 and document publication recovery 2026-11-03 00:55:19 +00:00
795cc2f379 Reuse cached pointers when ordering resolution changes 2026-11-02 17:40:12 +00:00
e878ce51ff Index collection prefixes for path correspondence 2026-11-02 17:36:55 +00:00
ebf98ffc62 Propagate matched sensitivity with one work queue 2026-11-02 17:33:44 +00:00
34c3241dae Report moved history at final record paths 2026-11-02 17:29:36 +00:00
25382a112b Preserve matched history for removed fields 2026-11-02 17:24:45 +00:00
95495ae7fc Record sequence resolution efficiency findings 2026-11-02 16:42:14 +00:00
27be6bab23 Record sequence resolution review findings 2026-11-02 16:36:30 +00:00
b066090db4 Verify sequence resolution through the public API 2026-11-02 15:00:48 +00:00
1d8438b635 Protect paired history snapshots from disclosure 2026-11-02 14:58:52 +00:00
3b10f97424 Carry sensitivity across matched records 2026-11-02 14:56:38 +00:00
c296eba6c1 Explain matched resolution and removed records 2026-11-02 14:54:47 +00:00
0799e1d18f Replay matched record input history at entry positions 2026-11-02 14:52:05 +00:00
151b56e7ec Select history using matched entry positions 2026-11-02 14:49:32 +00:00
4549fbaeef Preserve matched record metadata after resolution 2026-11-02 14:47:52 +00:00
c61597d137 Retain matched resolution evidence in history 2026-11-02 14:46:13 +00:00
d120fe907b Audit registered collection changes by identity 2026-11-02 14:43:48 +00:00
7f004f67ad Audit ordinary list changes at field paths 2026-11-02 14:41:42 +00:00
c0a0c753f9 Match registered collection endpoints for resolution 2026-11-02 14:40:04 +00:00
40b00da175 Record sequence resolution correspondence decision 2026-11-02 14:37:22 +00:00
1cee127300 Plan field-level sequence resolution and history 2026-11-02 14:32:15 +00:00
9adcf14966 Clarify history for removed records and reused positions 2026-11-02 14:20:58 +00:00
6b7a960d11 Define identity-aware resolution history in the sequence roadmap 2026-11-02 13:09:03 +00:00
128bf475c3 Record the proposal for field-level list resolution 2026-11-02 12:42:25 +00:00
69 changed files with 3318 additions and 426 deletions

View 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.

View 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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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
View 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)

View File

@@ -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`?

View File

@@ -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)
}

View File

@@ -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.

View 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")}},
})
}

View 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)
}
})
}
}

View File

@@ -0,0 +1,7 @@
mode: embedded
prompts:
- id: shared
text: embedded text
note: replaced
- id: bundled
text: bundled text

View File

@@ -0,0 +1 @@
mode: main

View File

@@ -0,0 +1,5 @@
config:
imports:
- path: ../../shared/extra.yml
id: local
text: local text

View File

@@ -0,0 +1 @@
mode: overlay

View File

@@ -0,0 +1,2 @@
id: shared
text: operator replacement

View File

@@ -0,0 +1 @@
note: transitive detail

View 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)
}
})
}
}

View 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)
}
})
}
}

View 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())
}
})
}
}

View 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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 {

View 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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
} }
} }

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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,
} }
} }

View File

@@ -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()

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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
} }

View File

@@ -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)

View 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)
}
}

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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),
} }
} }

View 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)
}

View 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)
}
}
}

View 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
}

View 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)
}
}

View 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")
}
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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))

View File

@@ -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,

View File

@@ -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