34 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
38 changed files with 1320 additions and 139 deletions

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`.
@@ -1489,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.
@@ -1504,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.
@@ -1629,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` |
| --- | --- | --- | --- | | --- | --- | --- | --- |

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

View File

@@ -66,6 +66,7 @@ Accepted decisions:
- [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) - [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
@@ -93,6 +94,12 @@ 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 The completed field-level sequence resolution roadmap, implementation plan, and
resolved audit findings are preserved in Git history at `7e53863` under resolved audit findings are preserved in Git history at `7e53863` under
`docs/roadmap/`. For example: `docs/roadmap/`. For example:

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

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;

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

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

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

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

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

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

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

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

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

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