Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3e7b62119 | |||
| cd730d5c99 |
@@ -7,6 +7,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const directiveKey = "configkit"
|
||||||
|
|
||||||
type importDeclaration struct {
|
type importDeclaration struct {
|
||||||
path string
|
path string
|
||||||
glob string
|
glob string
|
||||||
@@ -19,11 +21,11 @@ func splitImportDirectives(root *document.Node) (*document.Node, []importDeclara
|
|||||||
var declarations []importDeclaration
|
var declarations []importDeclaration
|
||||||
directiveIndex := -1
|
directiveIndex := -1
|
||||||
for index, entry := range body.Mapping {
|
for index, entry := range body.Mapping {
|
||||||
if entry.Key != "_configkit" {
|
if entry.Key != directiveKey {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
directiveIndex = index
|
directiveIndex = index
|
||||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit"))
|
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, nil, diagnostics
|
return nil, nil, diagnostics
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
|
|||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
childPath := current.Key(entry.Key)
|
childPath := current.Key(entry.Key)
|
||||||
if entry.Key == "_configkit" {
|
if entry.Key == directiveKey {
|
||||||
location := publicLocation(entry.KeyLocation)
|
location := publicLocation(entry.KeyLocation)
|
||||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
|
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
|
||||||
}
|
}
|
||||||
@@ -183,7 +185,7 @@ func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
|
|||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
childPath := current.Key(entry.Key)
|
childPath := current.Key(entry.Key)
|
||||||
if entry.Key == "_configkit" {
|
if entry.Key == directiveKey {
|
||||||
location := publicLocation(entry.KeyLocation)
|
location := publicLocation(entry.KeyLocation)
|
||||||
return []Diagnostic{{
|
return []Diagnostic{{
|
||||||
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
||||||
@@ -209,7 +211,7 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
|||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
childPath := path.Key(entry.Key)
|
childPath := path.Key(entry.Key)
|
||||||
if entry.Key == "_configkit" {
|
if entry.Key == directiveKey {
|
||||||
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
||||||
diagnostics = append(diagnostics, Diagnostic{
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ semantic validator, explicit path and glob imports, one selected profile, and
|
|||||||
filesystem context. It also accepts explicit CLI override records, static secret
|
filesystem context. It also accepts explicit CLI override records, static secret
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
and filesystem context as invalid combinations but accepts overrides. Input
|
||||||
containing a reserved `_configkit` mapping key is rejected while imports are
|
containing a reserved `configkit` mapping key is rejected while imports are
|
||||||
disabled.
|
disabled.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
@@ -99,6 +99,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
|
|||||||
metadata while retaining their base definition and the parent replacement operation
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
in history. All metadata queries and returned nested records are detached.
|
in history. All metadata queries and returned nested records are detached.
|
||||||
|
|
||||||
|
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
|
||||||
|
without reloading input or resolving unselected credentials. Consumers can use
|
||||||
|
it with `History` to validate ownership of declarations hidden by an overlay;
|
||||||
|
domain ownership rules remain application responsibilities. The method's Go
|
||||||
|
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
|
||||||
|
details.
|
||||||
|
|
||||||
History is chronological: a path can have a synthetic default record, its exact base
|
History is chronological: a path can have a synthetic default record, its exact base
|
||||||
definition, a profile replacement, and a recorded resolution operation. A descendant
|
definition, a profile replacement, and a recorded resolution operation. A descendant
|
||||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||||
@@ -419,7 +426,7 @@ origin data; history retains the displaced definitions and identifies the parent
|
|||||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
replacement. Surviving profile paths have profile origins, while recursively shared
|
||||||
mapping containers retain their contributing locations.
|
mapping containers retain their contributing locations.
|
||||||
|
|
||||||
A profile may not contain `_configkit` anywhere, contribute the configured schema
|
A profile may not contain `configkit` anywhere, contribute the configured schema
|
||||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||||
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
||||||
base parse, composition, or version failure because those checks run first.
|
base parse, composition, or version failure because those checks run first.
|
||||||
@@ -452,7 +459,7 @@ All records are detached and validated before any is applied. Root and explicit
|
|||||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
||||||
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||||
Neither paths nor values may create `_configkit`, and no record may overlap the
|
Neither paths nor values may create `configkit`, and no record may overlap the
|
||||||
configured schema-version path. Unknown fields and effective type mismatches remain
|
configured schema-version path. Unknown fields and effective type mismatches remain
|
||||||
ordinary strict-decoder errors.
|
ordinary strict-decoder errors.
|
||||||
|
|
||||||
@@ -475,12 +482,18 @@ zero.
|
|||||||
## Explicit imports
|
## Explicit imports
|
||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||||
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||||
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
|
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||||
imports disabled, any occurrence remains `import.disabled`.
|
imports disabled, any occurrence remains `import.disabled`.
|
||||||
|
|
||||||
|
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||||
|
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||||
|
normal schema validation, and does not trigger imports. When upgrading, rename
|
||||||
|
directive keys in root files and imported fragments, and rename any application
|
||||||
|
fields previously named `configkit` to avoid the newly reserved key.
|
||||||
|
|
||||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||||
and files are traversed depth-first in declaration order: a file contributes its own
|
and files are traversed depth-first in declaration order: a file contributes its own
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 0.1.0
|
**Base Policy Version:** 0.1.0
|
||||||
|
|
||||||
**Last Updated:** 2026-09-06
|
**Last Updated:** 2026-09-07
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -220,5 +220,12 @@ restating or redefining its requirements.
|
|||||||
|
|
||||||
## Policy Exceptions
|
## Policy Exceptions
|
||||||
|
|
||||||
None. Candidate selection, publication, and post-publication verification remain
|
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
|
||||||
mandatory steps in the [release procedure](../release.md), not policy exceptions.
|
`configkit` as an error-correcting point release because there are no current
|
||||||
|
consumers. This intentionally changes the v0.1.0 configuration contract without a
|
||||||
|
minor-version increment. The release note must explain the renamed directive,
|
||||||
|
the lack of a legacy alias, and the newly reserved application key. This exception
|
||||||
|
does not apply to subsequent releases or waive any validation or publication gate.
|
||||||
|
|
||||||
|
Candidate selection, publication, and post-publication verification remain
|
||||||
|
mandatory steps in the [release procedure](../release.md).
|
||||||
|
|||||||
43
docs/releases/v0.1.1.md
Normal file
43
docs/releases/v0.1.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ConfigKit v0.1.1
|
||||||
|
|
||||||
|
This release corrects the spelling of ConfigKit's reserved configuration key.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The reserved directive key is now `configkit` instead of `_configkit`. Import
|
||||||
|
declarations continue to use the same `imports` sequence and path/glob syntax.
|
||||||
|
See the [configuration contract](../config.md#explicit-imports).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is an intentional configuration compatibility change, approved as a one-time
|
||||||
|
[patch-release exception](../policy/release.md#policy-exceptions) because there are
|
||||||
|
no current consumers. `_configkit` is no longer a directive or a reserved key; it
|
||||||
|
is ordinary application data and is subject to normal schema validation. There is
|
||||||
|
no legacy alias. `configkit` is now reserved, including in nested mappings, profiles,
|
||||||
|
and override paths and values.
|
||||||
|
|
||||||
|
The Go API, diagnostic codes, Go minimum version, and supported platforms are
|
||||||
|
unchanged. The private YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release
|
||||||
|
during candidate preparation.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Rename `_configkit` directive keys to `configkit` in root configuration files and
|
||||||
|
imported fragments. Rename any application-owned `configkit` fields to avoid the
|
||||||
|
newly reserved key. Update diagnostic-path expectations to the new spelling.
|
||||||
|
Files retaining `_configkit` will not load their declared imports.
|
||||||
|
|
||||||
|
Consumers can select this release with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Renamed the directive key consistently across imports, profiles, and overrides.
|
||||||
|
- Updated current configuration documentation and the composed consumer fixture.
|
||||||
|
- Updated behavioral tests and added coverage proving the old spelling is not an
|
||||||
|
import alias.
|
||||||
50
docs/releases/v0.1.2.md
Normal file
50
docs/releases/v0.1.2.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ConfigKit v0.1.2
|
||||||
|
|
||||||
|
This release adds safe enumeration of effective and removed metadata paths.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
|
||||||
|
including paths removed by profiles, CLI overrides, or recorded resolution. This
|
||||||
|
allows applications to discover hidden declarations and inspect their ownership
|
||||||
|
through the existing `Metadata.History` API without reloading configuration or
|
||||||
|
resolving unselected credentials.
|
||||||
|
|
||||||
|
See the [layered metadata contract](../config.md#layered-metadata) for current
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible API addition. Existing APIs, configuration syntax,
|
||||||
|
diagnostic codes, minimum Go version, and supported platforms are unchanged.
|
||||||
|
Existing consumers do not need to change their code or configuration.
|
||||||
|
|
||||||
|
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
|
||||||
|
by that spelling. The enumeration includes defaults and the root, exposes no
|
||||||
|
configuration values, and performs no I/O or secret resolution.
|
||||||
|
|
||||||
|
The private YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during
|
||||||
|
candidate preparation.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No action required. Consumers that want historical path discovery can update with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
|
||||||
|
chronological definitions or `Lookup` to determine whether each path remains
|
||||||
|
effective.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
|
||||||
|
- Included effective, default, and removed paths while excluding paths that never
|
||||||
|
existed.
|
||||||
|
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
|
||||||
|
mapping keys, detached results, zero metadata, composite base ownership, and
|
||||||
|
avoidance of superseded secret lookup.
|
||||||
|
- Documented historical path enumeration in the current configuration contract.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
_configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: artifacts.yml
|
- path: artifacts.yml
|
||||||
poll_interval: 1m
|
poll_interval: 1m
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type explanationConcurrency struct {
|
|||||||
|
|
||||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
|
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
|
||||||
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||||
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||||
|
|||||||
10
glob_test.go
10
glob_test.go
@@ -10,13 +10,13 @@ import (
|
|||||||
|
|
||||||
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: fragments/*.yml
|
- glob: fragments/*.yml
|
||||||
- path: after.yml
|
- path: after.yml
|
||||||
items: {root: root}
|
items: {root: root}
|
||||||
`)
|
`)
|
||||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
|
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
|
||||||
imports: [{path: nested/child.yml}]
|
imports: [{path: nested/child.yml}]
|
||||||
items: {alpha: alpha}
|
items: {alpha: alpha}
|
||||||
`)
|
`)
|
||||||
@@ -56,7 +56,7 @@ items: {alpha: alpha}
|
|||||||
|
|
||||||
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: 'conf.d/literal\*.yml'
|
- glob: 'conf.d/literal\*.yml'
|
||||||
- glob: 'conf.d/item-[ab].yml'
|
- glob: 'conf.d/item-[ab].yml'
|
||||||
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
|||||||
t.Run("overlapping declarations", func(t *testing.T) {
|
t.Run("overlapping declarations", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: conf/*.yml
|
- glob: conf/*.yml
|
||||||
- glob: conf/value.*
|
- glob: conf/value.*
|
||||||
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
|
|||||||
if optional {
|
if optional {
|
||||||
optionalLine = "\n optional: true"
|
optionalLine = "\n optional: true"
|
||||||
}
|
}
|
||||||
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,37 @@ type importTestConfig struct {
|
|||||||
Items map[string]string `yaml:"items"`
|
Items map[string]string `yaml:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Legacy struct {
|
||||||
|
Imports []struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
} `yaml:"imports"`
|
||||||
|
} `yaml:"_configkit"`
|
||||||
|
}
|
||||||
|
for _, imports := range []bool{false, true} {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
|
||||||
|
result, err := Load[config](File(mainPath), Options[config]{Imports: imports})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Imports=%v: %v", imports, err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Legacy.Imports) != 1 || result.Config.Legacy.Imports[0].Path != "nonexistent.yml" || len(result.Sources()) != 1 {
|
||||||
|
t.Fatalf("Imports=%v: former directive was not retained as application data: %#v", imports, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: first.yml
|
- path: first.yml
|
||||||
- path: second.yml
|
- path: second.yml
|
||||||
settings:
|
settings:
|
||||||
root: root
|
root: root
|
||||||
`)
|
`)
|
||||||
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
|
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: nested/third.yml
|
- path: nested/third.yml
|
||||||
settings:
|
settings:
|
||||||
@@ -71,7 +92,7 @@ settings:
|
|||||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: one.yml
|
- path: one.yml
|
||||||
- path: two.yml
|
- path: two.yml
|
||||||
@@ -97,7 +118,7 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: fragment.yml\n")
|
||||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -114,15 +135,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
|
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
|
||||||
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
|
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||||
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
|
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||||
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
||||||
{name: "missing target", body: "_configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "two targets", body: "_configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "unknown declaration field", body: "_configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||||
{name: "optional kind", body: "_configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
|
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -136,7 +157,7 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||||
t.Run("optional missing", func(t *testing.T) {
|
t.Run("optional missing", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: missing.yml
|
- path: missing.yml
|
||||||
optional: true
|
optional: true
|
||||||
@@ -172,7 +193,7 @@ settings: {root: retained}
|
|||||||
if test.setup != nil {
|
if test.setup != nil {
|
||||||
test.setup(t, directory)
|
test.setup(t, directory)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,9 +202,9 @@ settings: {root: retained}
|
|||||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||||
t.Run("cycle", func(t *testing.T) {
|
t.Run("cycle", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
|
||||||
writeImportFile(t, directory, "a.yml", "_configkit:\n imports: [{path: b.yml}]\n")
|
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
|
||||||
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
|
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
||||||
@@ -193,9 +214,9 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("diamond", func(t *testing.T) {
|
t.Run("diamond", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||||
writeImportFile(t, directory, "left.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||||
writeImportFile(t, directory, "right.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
writeImportFile(t, directory, "right.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||||
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -216,7 +237,7 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -231,7 +252,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: link.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
|
||||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
|
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
|
||||||
t.Fatalf("symlink result = %#v, %v", result, err)
|
t.Fatalf("symlink result = %#v, %v", result, err)
|
||||||
@@ -245,7 +266,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, root, "main.yml", "_configkit: {imports: [{path: escape.yml}]}\n")
|
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,7 +276,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
||||||
t.Skipf("hard links unavailable: %v", err)
|
t.Skipf("hard links unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,7 +285,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: loop.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -272,7 +293,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
||||||
input := []byte("_configkit: {imports: [{path: fragment.yml}]}\n")
|
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
|
||||||
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -295,7 +316,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
|
|||||||
|
|
||||||
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mainPath := writeImportFile(t, root, "configs/main.yml", "_configkit: {imports: [{path: ../shared.yml}]}\n")
|
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
|
||||||
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
||||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||||
@@ -309,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
|||||||
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||||
t.Run("parse", func(t *testing.T) {
|
t.Run("parse", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: broken.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||||
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
||||||
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
diagnostics := loadError.Diagnostics()
|
diagnostics := loadError.Diagnostics()
|
||||||
@@ -320,7 +341,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("decode", func(t *testing.T) {
|
t.Run("decode", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: unknown.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
|
||||||
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -332,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "outside.yml", "_configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
|
|
||||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||||
@@ -344,13 +365,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
|||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
||||||
}
|
}
|
||||||
mainPath = writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||||
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
||||||
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
@@ -370,7 +391,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeImportFile(t, directory, "one.yml", "_configkit: {imports: [{path: nested.yml}]}\n")
|
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||||
main = parseImportMain(t, mainPath)
|
main = parseImportMain(t, mainPath)
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||||
@@ -381,7 +402,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
|
|
||||||
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: imported.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
||||||
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
imported := parseImportMain(t, importedPath)
|
imported := parseImportMain(t, importedPath)
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n _configkit: {}\n")))
|
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
|
||||||
if result != nil || err == nil {
|
if result != nil || err == nil {
|
||||||
t.Fatalf("result, err = %#v, %v", result, err)
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
||||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
metadata.go
32
metadata.go
@@ -1,6 +1,10 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
// Metadata provides detached effective presence/origin and chronological history
|
// Metadata provides detached effective presence/origin and chronological history
|
||||||
// queries for a load. Its zero value contains no paths.
|
// queries for a load. Its zero value contains no paths.
|
||||||
@@ -50,6 +54,32 @@ func (m Metadata) History(path Path) []Change {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoricalPaths returns the paths observed during loading, including effective
|
||||||
|
// paths and paths removed by overlays or recorded resolution. It includes default
|
||||||
|
// paths and the root, but not paths that never existed. The zero Metadata returns
|
||||||
|
// an empty slice.
|
||||||
|
//
|
||||||
|
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
|
||||||
|
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
|
||||||
|
// one pointer can identify a mapping key in an earlier layer and a sequence index
|
||||||
|
// in a later one. Use History to inspect definitions and Lookup to check whether
|
||||||
|
// a path remains effective. Enumeration exposes no configuration values and does
|
||||||
|
// not perform another load or resolve secrets.
|
||||||
|
func (m Metadata) HistoricalPaths() []Path {
|
||||||
|
pointers := make([]string, 0, len(m.historicalPaths))
|
||||||
|
for pointer := range m.historicalPaths {
|
||||||
|
pointers = append(pointers, pointer)
|
||||||
|
}
|
||||||
|
sort.Strings(pointers)
|
||||||
|
paths := make([]Path, 0, len(pointers))
|
||||||
|
for _, pointer := range pointers {
|
||||||
|
// The index contains only canonical pointers produced by Path.String.
|
||||||
|
path, _ := ParsePath(pointer)
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
||||||
// the query. Candidate paths are constructed from actual document containers.
|
// the query. Candidate paths are constructed from actual document containers.
|
||||||
func matchesPathShape(query, candidate Path) bool {
|
func matchesPathShape(query, candidate Path) bool {
|
||||||
|
|||||||
@@ -2,9 +2,95 @@ package configkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMetadataHistoricalPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
|
||||||
|
Overrides: []Override{
|
||||||
|
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
|
||||||
|
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
paths := metadata.HistoricalPaths()
|
||||||
|
var got []string
|
||||||
|
for _, path := range paths {
|
||||||
|
got = append(got, path.String())
|
||||||
|
if len(metadata.History(path)) == 0 {
|
||||||
|
t.Fatalf("enumerated path %s has no history", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("historical paths = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||||
|
t.Fatal("removed descendant remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||||
|
t.Fatal("enumerated numeric path cannot query the new sequence shape")
|
||||||
|
}
|
||||||
|
// Returned slices and nested paths must not alias retained metadata.
|
||||||
|
paths[1].segments[0].key = "mutated"
|
||||||
|
paths[2] = Keys("replacement")
|
||||||
|
if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] {
|
||||||
|
t.Fatal("enumeration returned aliased paths")
|
||||||
|
}
|
||||||
|
if len((Metadata{}).HistoricalPaths()) != 0 {
|
||||||
|
t.Fatal("zero metadata contains paths")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
|
||||||
|
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
|
||||||
|
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
|
||||||
|
type config struct {
|
||||||
|
Credential *Secret `yaml:"credential"`
|
||||||
|
Definitions map[string]map[string]string `yaml:"definitions"`
|
||||||
|
}
|
||||||
|
for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} {
|
||||||
|
t.Run(credential, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
|
||||||
|
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
|
||||||
|
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
|
||||||
|
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||||
|
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
found := false
|
||||||
|
for _, path := range metadata.HistoricalPaths() {
|
||||||
|
if path.String() != "/definitions/recap" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
owners := map[string]bool{}
|
||||||
|
for _, change := range metadata.History(path) {
|
||||||
|
if change.Origin.Layer == LayerBase {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
owners[location.SourceID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(owners) != 2 {
|
||||||
|
t.Fatalf("hidden declaration owners = %v", owners)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("hidden base declaration was not discoverable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func overlappingOverride(previous []Override, valid []bool, path Path) int {
|
|||||||
|
|
||||||
func pathContainsDirective(path Path) bool {
|
func pathContainsDirective(path Path) bool {
|
||||||
for _, segment := range path.segments {
|
for _, segment := range path.segments {
|
||||||
if segment.key == "_configkit" {
|
if segment.key == directiveKey {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
|||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
child := path.Key(entry.Key)
|
child := path.Key(entry.Key)
|
||||||
if entry.Key == "_configkit" {
|
if entry.Key == directiveKey {
|
||||||
return child, true
|
return child, true
|
||||||
}
|
}
|
||||||
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
|
|||||||
|
|
||||||
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
||||||
writeImportFile(t, directory, "base.yml", `settings:
|
writeImportFile(t, directory, "base.yml", `settings:
|
||||||
target: base
|
target: base
|
||||||
count: 4
|
count: 4
|
||||||
@@ -187,8 +187,8 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
||||||
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
|
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
|
||||||
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
|
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
|
||||||
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
{name: "directive path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
||||||
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
||||||
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
|
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
|
||||||
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
|
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
|
||||||
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type profileTestConfig struct {
|
|||||||
|
|
||||||
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: base.yml}]
|
imports: [{path: base.yml}]
|
||||||
settings:
|
settings:
|
||||||
kept: main
|
kept: main
|
||||||
@@ -133,8 +133,8 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
profile string
|
profile string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
|
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
||||||
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
|
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
||||||
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
||||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||||
} {
|
} {
|
||||||
@@ -158,7 +158,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("profile aliases imported base", func(t *testing.T) {
|
t.Run("profile aliases imported base", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type layeredConfig struct {
|
|||||||
|
|
||||||
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: imported.yml}]
|
imports: [{path: imported.yml}]
|
||||||
shared: {equal: same}
|
shared: {equal: same}
|
||||||
values: [1, 2]
|
values: [1, 2]
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
input := "value: 1\n"
|
input := "value: 1\n"
|
||||||
if mode == "import" {
|
if mode == "import" {
|
||||||
input = "_configkit:\n imports:\n - path: pipe.yml\n"
|
input = "configkit:\n imports:\n - path: pipe.yml\n"
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user