Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a885934c2 | |||
| 5917cebf50 | |||
| 8e91a2b16b | |||
| 08402d098b | |||
| 53b79528d9 | |||
| f849e06b77 | |||
| b37948cfea | |||
| 9323f89ad1 | |||
| 6c62226838 | |||
| 799ebab5a6 | |||
| c8546ae599 | |||
| d32c94eca3 | |||
| 344f4f73d7 | |||
| 5fedc11c8f | |||
| 5b8b7a2415 | |||
| e591e97f22 | |||
| 71e142afb4 | |||
| ca1ba04676 | |||
| 528495c75a | |||
| aa3b4a5881 | |||
| db2d8ed217 | |||
| d34a98d34b | |||
| 9ac65ccc08 | |||
| edc6660654 | |||
| a2084df11a | |||
| b59f002314 | |||
| 52793301ba | |||
| 0c77deed05 | |||
| 48619de024 | |||
| c546124029 | |||
| 673faea097 | |||
| 91ae4fa7ef | |||
| f1722274c0 | |||
| d53021ea2c | |||
| a22aaa5dfa | |||
| b1ecd9a078 | |||
| 84b59c690d | |||
| ac90b9835b | |||
| f887fac809 | |||
| c03457c814 | |||
| e9b6b63e5e | |||
| 1dc50d8cdf | |||
| 0e92085002 | |||
| f96f2b8295 | |||
| 1917dcf921 | |||
| 0ac4731903 | |||
| c42930c6d5 | |||
| 2d3ea588f1 | |||
| 5aafd943b4 | |||
| c0605fab44 |
@@ -66,7 +66,7 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
||||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
||||||
// Log only targets selected by the script, not inherited host settings.
|
// Log only targets selected by the script, not inherited host settings.
|
||||||
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
||||||
if name == "failed-go-check" {
|
if name == "failed-go-check" {
|
||||||
@@ -108,90 +108,3 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckScriptsReleaseGates(t *testing.T) {
|
|
||||||
script, err := os.ReadFile("scripts/release-check.sh")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, scenario := range []string{"success", "failed-test", "missing-toolchain"} {
|
|
||||||
t.Run(scenario, func(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
bin := t.TempDir()
|
|
||||||
for _, directory := range []string{"scripts", "nested"} {
|
|
||||||
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checker := filepath.Join(root, "scripts", "release-check.sh")
|
|
||||||
if err := os.WriteFile(checker, script, 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
calls := filepath.Join(bin, "go.calls")
|
|
||||||
goStub := `#!/bin/sh
|
|
||||||
printf '%s|%s|%s|%s|%s|%s\n' "$GOTOOLCHAIN" "$GOPROXY" "${CGO_ENABLED-}" "${GOOS-}" "${GOARCH-}" "$*" >> "$CONFIGKIT_TEST_GO_LOG"
|
|
||||||
if [ "${CONFIGKIT_TEST_FAIL-}" = toolchain ] && [ "${1-}" = version ]; then exit 37; fi
|
|
||||||
if [ "${CONFIGKIT_TEST_FAIL-}" = test ] && [ "${1-}" = test ]; then exit 37; fi
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(goStub), 0700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("/bin/bash", checker)
|
|
||||||
cmd.Dir = filepath.Join(root, "nested")
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
||||||
"CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline",
|
|
||||||
"CONFIGKIT_TEST_GO_LOG="+calls,
|
|
||||||
"GOOS=", "GOARCH=", "CGO_ENABLED=",
|
|
||||||
)
|
|
||||||
switch scenario {
|
|
||||||
case "failed-test":
|
|
||||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=test")
|
|
||||||
case "missing-toolchain":
|
|
||||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=toolchain")
|
|
||||||
}
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if scenario == "success" && err != nil {
|
|
||||||
t.Fatalf("checker: %v\n%s", err, output)
|
|
||||||
}
|
|
||||||
if scenario != "success" && err == nil {
|
|
||||||
t.Fatalf("checker accepted %s", scenario)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(calls)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
if !strings.HasPrefix(line, "test-baseline|off|") {
|
|
||||||
t.Fatalf("toolchain or offline setting lost: %q", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scenario == "missing-toolchain" {
|
|
||||||
if len(lines) != 1 || !strings.HasSuffix(lines[0], "|version") {
|
|
||||||
t.Fatalf("checker ran after unavailable toolchain: %q", lines)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(lines) < 2 || lines[1] != "test-baseline|off||||test -mod=readonly -vet=off -race -v ./..." {
|
|
||||||
t.Fatalf("release suite command changed: %q", lines)
|
|
||||||
}
|
|
||||||
if scenario == "failed-test" {
|
|
||||||
if len(lines) != 2 {
|
|
||||||
t.Fatalf("checker continued after failure: %q", lines)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"test-baseline|off||||version",
|
|
||||||
"test-baseline|off||||test -mod=readonly -vet=off -race -v ./...",
|
|
||||||
"test-baseline|off||||vet -mod=readonly ./...",
|
|
||||||
"test-baseline|off|0|linux|amd64|build -mod=readonly ./...",
|
|
||||||
"test-baseline|off|0|linux|arm64|build -mod=readonly ./...",
|
|
||||||
}
|
|
||||||
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Fatalf("unexpected release gates:\n%s", data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,8 +64,9 @@ git show cb5b435:docs/roadmap/remediation.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Release-candidate, publication, and verification work follows the operational
|
Release-candidate, publication, and verification work follows the operational
|
||||||
[release procedure](release.md). Its single publication command validates the
|
[release procedure](release.md). Its module-selected ReleaseKit `publish`
|
||||||
committed candidate and publishes only the intended refs.
|
invocation validates the committed candidate and publishes only the intended
|
||||||
|
tag; those release checks remain distinct from the everyday development check.
|
||||||
|
|
||||||
The completed string-and-integer schema-version implementation and remediation
|
The completed string-and-integer schema-version implementation and remediation
|
||||||
records are preserved in Git history at `244104f`. The
|
records are preserved in Git history at `244104f`. The
|
||||||
@@ -93,9 +94,20 @@ preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
|||||||
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||||
and release operations belong to the [release procedure](release.md).
|
and release operations belong to the [release procedure](release.md).
|
||||||
|
|
||||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
The completed ReleaseKit adoption, startup performance request and experiments,
|
||||||
dependencies explicitly before entering an offline development or validation
|
benchmark evidence, performance audit, implementation plan, and remediation
|
||||||
environment:
|
record are preserved in Git history at `8e91a2b` under `docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Remaining optimization candidates belong to
|
||||||
|
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
|
||||||
|
|
||||||
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
|
Acquire declared dependencies explicitly before entering an offline
|
||||||
|
development or validation environment:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go mod download
|
go mod download
|
||||||
@@ -125,11 +137,10 @@ Use focused checks while iterating, with validation proportionate to the change
|
|||||||
under the testing policy. Documentation-only work normally needs the local-link
|
under the testing policy. Documentation-only work normally needs the local-link
|
||||||
check below and whitespace review; behavior-bearing examples and commands also
|
check below and whitespace review; behavior-bearing examples and commands also
|
||||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||||
The [publication command](release.md#4-publish-the-committed-candidate) invokes
|
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
||||||
the release checker once. It performs one minimum-toolchain offline race-suite
|
its selected validation through one `publish` invocation. The everyday checker
|
||||||
run, one full vet pass, and Linux cross-builds using readonly module mode.
|
remains a development gate and is not a separate release prerequisite. CI never
|
||||||
It already covers examples, links, and package boundaries; do not additionally
|
gates a release.
|
||||||
run the everyday checker just to repeat those checks. CI never gates a release.
|
|
||||||
|
|
||||||
## Task-to-test map
|
## Task-to-test map
|
||||||
|
|
||||||
@@ -154,8 +165,8 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
| Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
| Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` |
|
| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
|
||||||
|
|
||||||
## Fuzz validation
|
## Fuzz validation
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## Standard Repository Policies
|
## Standard Repository Policies
|
||||||
|
|
||||||
Material: `docs/policy/meta.md`, `docs/policy/oss.md`,
|
Material: `docs/policy/release.md`, adapted from
|
||||||
|
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
||||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
||||||
`docs/policy/architecture.md`, `docs/policy/testing.md`, and
|
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||||
`docs/policy/release.md`; root `AGENTS.md`; and the Policy Reading Guide in
|
Policy Reading Guide in `docs/development.md`, adapted from
|
||||||
`docs/development.md`, adapted for this repository.
|
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
@@ -14,10 +15,14 @@ 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) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/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).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Release Policy
|
# Release Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.4
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-09-28
|
**Last Updated**: 2026-10-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ This policy defines a proportionate workflow for validating and publishing an
|
|||||||
intended source revision under a fresh version. Releases should require little
|
intended source revision under a fresh version. Releases should require little
|
||||||
routine intervention, avoid repeated work, and preserve published contents.
|
routine intervention, avoid repeated work, and preserve published contents.
|
||||||
|
|
||||||
|
Adopt stronger verification or reproducibility requirements through an explicit
|
||||||
|
project-specific decision justified by the intended audience and support needs.
|
||||||
|
|
||||||
The repository-specific commands belong in `docs/release.md`. Historical release
|
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||||
policy and link to it rather than introduce additional release requirements.
|
policy and link to it rather than introduce additional release requirements.
|
||||||
@@ -54,10 +57,11 @@ authoritative primary branch, completes the release. The source commit may be
|
|||||||
pushed before the tag or published together with it through an atomic push.
|
pushed before the tag or published together with it through an atomic push.
|
||||||
The procedure must choose and document the supported approach.
|
The procedure must choose and document the supported approach.
|
||||||
|
|
||||||
ConfigKit uses sequential explicit pushes: first the captured candidate to
|
ConfigKit uses sequential publication: first push the committed candidate to
|
||||||
authoritative `main`, then the annotated tag. Its
|
authoritative `main`, then invoke ReleaseKit once to validate that candidate and
|
||||||
[release procedure](../release.md) names `origin` and checks that its fetch and
|
publish its annotated tag. The [release procedure](../release.md) names
|
||||||
push destinations resolve to the authoritative URL.
|
`origin`; ReleaseKit checks its exact effective push URL before validation and
|
||||||
|
tag publication.
|
||||||
|
|
||||||
Release commands must finish at successful publication. They must not wait for
|
Release commands must finish at successful publication. They must not wait for
|
||||||
or poll CI, require successful CI status, inspect downloadable artifacts, or
|
or poll CI, require successful CI status, inspect downloadable artifacts, or
|
||||||
@@ -76,8 +80,9 @@ the tagged source. The procedure must identify those deliverables and the
|
|||||||
responsibility of their automation. Published deliverables must be traceable
|
responsibility of their automation. Published deliverables must be traceable
|
||||||
to that source and identify the same version.
|
to that source and identify the same version.
|
||||||
|
|
||||||
ConfigKit currently declares no additional deliverables. Its source-tag push
|
ConfigKit currently declares no additional deliverables. Confirmed publication
|
||||||
is the completion point.
|
of the intended source tag is the completion point. CI, macOS validation,
|
||||||
|
hosted delivery, and consumer retrieval are outside the release gates.
|
||||||
|
|
||||||
## Versioning And Compatibility
|
## Versioning And Compatibility
|
||||||
|
|
||||||
@@ -96,7 +101,8 @@ requires a deliberate update to the policy and procedure where needed.
|
|||||||
For releases at or after `v1.0.0`:
|
For releases at or after `v1.0.0`:
|
||||||
|
|
||||||
- a major release may make intentional incompatible changes;
|
- a major release may make intentional incompatible changes;
|
||||||
- a minor release may add backward-compatible behavior; and
|
- a minor release may add backward-compatible behavior or raise the Go consumer
|
||||||
|
minimum as described below; and
|
||||||
- a patch release may make backward-compatible fixes.
|
- a patch release may make backward-compatible fixes.
|
||||||
|
|
||||||
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,
|
||||||
@@ -108,13 +114,53 @@ Additional compatibility promises and deliberate exceptions belong in the
|
|||||||
adopted policy or canonical contract documentation. Release notes explain
|
adopted policy or canonical contract documentation. Release notes explain
|
||||||
changes; they do not redefine current compatibility contracts.
|
changes; they do not redefine current compatibility contracts.
|
||||||
|
|
||||||
|
## Consumer Minimum And Release Toolchain
|
||||||
|
|
||||||
|
Distinguish the minimum toolchain supported for consumers from the toolchain
|
||||||
|
maintainers use to build and validate a release. Record the consumer minimum in
|
||||||
|
the language's standard metadata and explain its support meaning in canonical
|
||||||
|
documentation. Define the selected release toolchain and its setup commands in
|
||||||
|
`docs/release.md`. Toolchain selection and upgrade cadence are repository-specific
|
||||||
|
choices.
|
||||||
|
|
||||||
|
For Go repositories, normally declare the initial stable release of the selected
|
||||||
|
series as the consumer minimum, such as `go 1.26.0`. Use a higher patch minimum
|
||||||
|
when a dependency or an identified correctness fix requires it, and document
|
||||||
|
the reason.
|
||||||
|
|
||||||
|
For release-policy purposes, successful build and release validation on the
|
||||||
|
selected Go patch release are sufficient to presume support across that Go
|
||||||
|
series, subject to the declared consumer minimum and concrete evidence of
|
||||||
|
incompatibility. Investigate specific compatibility concerns as they arise.
|
||||||
|
|
||||||
|
Use the selected release toolchain for candidate validation and release builds,
|
||||||
|
including CI jobs performing those tasks. A newer validation patch version alone
|
||||||
|
leaves the consumer minimum unchanged.
|
||||||
|
|
||||||
|
A minor release may raise the Go consumer minimum, including from one Go series
|
||||||
|
to the next, both before and after `v1.0.0`. This is an explicit exception to the
|
||||||
|
backward-compatibility requirement for minor releases. Patch releases preserve
|
||||||
|
the consumer minimum. Explain an increase's impact and required consumer action
|
||||||
|
in the release note. Other incompatible changes remain subject to the versioning
|
||||||
|
rules above.
|
||||||
|
|
||||||
|
### ConfigKit Toolchain Selection
|
||||||
|
|
||||||
|
For minor and major releases, select the newest stable Go series available in
|
||||||
|
the development environment. For patch releases, retain the series recorded in
|
||||||
|
`go.mod` and its consumer minimum. Use the latest locally available compatible
|
||||||
|
patch in the selected series, with `GOTOOLCHAIN=local`, to build the
|
||||||
|
module-selected ReleaseKit CLI and validate the candidate. The same Go
|
||||||
|
executable builds the tool and runs its validation commands.
|
||||||
|
|
||||||
## Release Documentation And Preparation
|
## Release Documentation And Preparation
|
||||||
|
|
||||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||||
source tree. Use the complete version tag as the filename and begin with a
|
source tree. Use the complete version tag as the filename and begin with a
|
||||||
title containing the product name and that exact version.
|
title containing the product name and that exact version.
|
||||||
|
|
||||||
ConfigKit requires the first line `# ConfigKit <version>`.
|
ConfigKit requires the first line `# ConfigKit <version>`. ReleaseKit's
|
||||||
|
publication guard checks the release-note H1 for the product and version.
|
||||||
|
|
||||||
Include a short summary and any relevant compatibility impact or upgrade
|
Include a short summary and any relevant compatibility impact or upgrade
|
||||||
instructions. Choose sections suited to the release and keep the note
|
instructions. Choose sections suited to the release and keep the note
|
||||||
@@ -146,10 +192,9 @@ checks publication state, and publishes the intended refs. A validation-only
|
|||||||
mode or command may be provided for diagnosis, but must not be a required step
|
mode or command may be provided for diagnosis, but must not be a required step
|
||||||
before publication repeats the same checks.
|
before publication repeats the same checks.
|
||||||
|
|
||||||
ConfigKit uses `./scripts/release.sh vMAJOR.MINOR.PATCH`. The separate
|
ConfigKit uses the module-selected ReleaseKit CLI's `publish
|
||||||
`scripts/release-check.sh` remains an optional diagnostic command; the
|
vMAJOR.MINOR.PATCH` command. Its `check` command is optional for diagnosis; it
|
||||||
publication command invokes it once and does not use a persistent candidate
|
is not a required preliminary pass to publication.
|
||||||
record.
|
|
||||||
|
|
||||||
Run the complete deterministic, offline default test suite once for the final
|
Run the complete deterministic, offline default test suite once for the final
|
||||||
candidate, following the [testing policy](testing.md). For repositories that
|
candidate, following the [testing policy](testing.md). For repositories that
|
||||||
@@ -157,13 +202,17 @@ publish builds, verify the required build targets. Select those targets for
|
|||||||
the application's actual support commitments; native testing on each platform
|
the application's actual support commitments; native testing on each platform
|
||||||
and access to particular hardware are not default release prerequisites.
|
and access to particular hardware are not default release prerequisites.
|
||||||
|
|
||||||
ConfigKit's one maintained Go 1.25 baseline runs the full suite with the race
|
Prepare dependencies and build the tool before offline validation; the
|
||||||
detector and test-time vet disabled. This protects library behavior, ownership,
|
publication command is the required validation run, with no separate successful
|
||||||
and concurrent use; the suite already includes examples, consumer fixtures,
|
test or CI run required before it.
|
||||||
documentation links, and package boundaries. One full vet pass adds static
|
|
||||||
checks beyond the test-time subset. CGO-disabled Linux amd64 and arm64 builds
|
The selected profile runs the complete deterministic, offline default suite
|
||||||
cover supported configurations distinct from native race compilation. macOS
|
once with the race detector and test-time vet disabled. The suite already
|
||||||
and newer-toolchain checks are optional.
|
includes examples, consumer fixtures, documentation links, and package
|
||||||
|
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
||||||
|
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||||
|
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||||
|
checks are optional.
|
||||||
|
|
||||||
Additional gates must address a concrete risk that the existing checks do not
|
Additional gates must address a concrete risk that the existing checks do not
|
||||||
adequately cover. Do not automatically require linting, race testing, module
|
adequately cover. Do not automatically require linting, race testing, module
|
||||||
@@ -172,9 +221,10 @@ expanded platform matrices solely because they are possible. Retain such
|
|||||||
checks when their distinct protection justifies their cost. A one-time migration
|
checks when their distinct protection justifies their cost. A one-time migration
|
||||||
check must not silently become a permanent release requirement.
|
check must not silently become a permanent release requirement.
|
||||||
|
|
||||||
The selected Go commands use readonly module mode with `GOPROXY=off`; acquire
|
ReleaseKit runs its selected Go commands with readonly module mode and
|
||||||
the baseline toolchain and dependencies before validation. Formatting and
|
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||||
whitespace remain ordinary development checks in `scripts/check.sh`.
|
validation. Formatting and whitespace remain ordinary development checks in
|
||||||
|
`scripts/check.sh`.
|
||||||
Review module tidiness when dependencies change, not as a release gate.
|
Review module tidiness when dependencies change, not as a release gate.
|
||||||
|
|
||||||
Release tests must not require real credentials, mutable application services,
|
Release tests must not require real credentials, mutable application services,
|
||||||
@@ -206,16 +256,19 @@ Automate the checks needed to publish the intended source under a fresh version:
|
|||||||
authoritative remote;
|
authoritative remote;
|
||||||
- the worktree and index are clean on the primary release branch;
|
- the worktree and index are clean on the primary release branch;
|
||||||
- the source being published is the exact candidate that passed validation,
|
- the source being published is the exact candidate that passed validation,
|
||||||
including its matching release note;
|
including its matching release note H1;
|
||||||
- the remote primary branch can be advanced to that candidate without
|
- the remote primary branch can be advanced to that candidate without
|
||||||
discarding remote history, and is published before the tag; and
|
discarding remote history, and is published before the tag; and
|
||||||
- the tag identifies the validated candidate and only the intended refs are
|
- the tag identifies the validated candidate and only the intended refs are
|
||||||
published.
|
published.
|
||||||
|
|
||||||
ConfigKit also checks that `origin` has exactly the authoritative fetch and
|
ReleaseKit checks that `origin`'s effective push URL exactly matches the
|
||||||
push destination, including configured push URLs. The command checks the
|
authoritative URL before candidate validation. This does not assert the
|
||||||
candidate and unused version again after validation, and checks local state
|
configured fetch URL or require the release-note H1 to be the first line; the
|
||||||
before creating the tag. Ignored local files do not block publication.
|
required `# ConfigKit <version>` first-line format remains a content rule. The
|
||||||
|
command checks the candidate and unused version again after validation, and
|
||||||
|
checks local state before creating the tag. Ignored local files do not block
|
||||||
|
publication.
|
||||||
|
|
||||||
Fetch the authoritative branch and tag state as needed for these checks.
|
Fetch the authoritative branch and tag state as needed for these checks.
|
||||||
Do not force branch or tag updates to bypass a failed guard. Publish explicit
|
Do not force branch or tag updates to bypass a failed guard. Publish explicit
|
||||||
@@ -248,10 +301,10 @@ release without replacing its contents or reusing its version.
|
|||||||
## Repository Release Procedure
|
## Repository Release Procedure
|
||||||
|
|
||||||
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
||||||
identify prerequisites, the primary branch and authoritative remote, version
|
identify prerequisites, the selected release toolchain, the primary branch and
|
||||||
and note preparation, one publication command with local checks and guards,
|
authoritative remote, version and note preparation, one publication command with
|
||||||
source-only deliverables, the successful-tag-push completion point, and
|
local checks and guards, source-only deliverables, the successful-tag-push
|
||||||
partial or unknown-state recovery.
|
completion point, and partial or unknown-state recovery.
|
||||||
|
|
||||||
Use copyable commands where practical. Keep optional diagnostics and consumer
|
Use copyable commands where practical. Keep optional diagnostics and consumer
|
||||||
verification clearly optional. The procedure must not turn preparation work,
|
verification clearly optional. The procedure must not turn preparation work,
|
||||||
|
|||||||
298
docs/release.md
298
docs/release.md
@@ -1,7 +1,7 @@
|
|||||||
# ConfigKit Release Procedure
|
# ConfigKit Release Procedure
|
||||||
|
|
||||||
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
||||||
ConfigKit publishes one source-only Go module. The immutable annotated tag and
|
ConfigKit publishes a source-only Go module. The immutable annotated tag and
|
||||||
matching checked-in release note are the deliverables; there is no binary,
|
matching checked-in release note are the deliverables; there is no binary,
|
||||||
container image, generated package, or separate registry publication.
|
container image, generated package, or separate registry publication.
|
||||||
|
|
||||||
@@ -9,183 +9,221 @@ container image, generated package, or separate registry publication.
|
|||||||
|
|
||||||
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
||||||
- Primary branch: `main`
|
- Primary branch: `main`
|
||||||
- Authoritative remote: `origin`, with fetch and push destination
|
- Authoritative remote: `origin`
|
||||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||||
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
||||||
- Minimum Go line: Go 1.25, validated with one maintained patch
|
- Minimum Go version: the first stable patch of the selected Go series, or a
|
||||||
|
higher patch when required by a dependency or specific correctness fix, as
|
||||||
|
recorded in `go.mod`; see the
|
||||||
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
- Release-gated targets: Linux amd64 and arm64
|
- Release-gated targets: Linux amd64 and arm64
|
||||||
- Best-effort targets: macOS amd64 and arm64, under the
|
- Best-effort targets: macOS amd64 and arm64, under the
|
||||||
[platform support policy](policy/release.md#platform-support)
|
[platform support policy](policy/release.md#platform-support)
|
||||||
|
|
||||||
The checker runs one offline race suite, one full vet pass, and CGO-disabled
|
Release validation runs one offline race-enabled Go suite, one full vet pass,
|
||||||
builds for both Linux targets. It reports skipped filesystem cases. macOS checks
|
and CGO-disabled package compilation for both Linux targets. macOS and CI are
|
||||||
and newer-toolchain tests are optional. CI is never a readiness or completion
|
not release gates; do not wait for or poll CI.
|
||||||
gate; do not wait for or poll it.
|
|
||||||
|
|
||||||
## 1. Prepare access and tools
|
## 1. Select Go and prepare the tool
|
||||||
|
|
||||||
Use a clean checkout with Git, Bash 3.2 or newer, a maintained Go 1.25 patch,
|
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
|
||||||
and a native compiler for race tests. Configure private-host access through the
|
tests. Choose the release type before selecting Go. For a minor or major release,
|
||||||
normal credential manager. Keep credentials and environment dumps outside the
|
select the newest stable Go series available in the development environment.
|
||||||
repository and command transcript.
|
For a patch release, retain the series recorded in `go.mod` and leave its consumer
|
||||||
|
minimum unchanged. Follow the
|
||||||
|
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
|
||||||
|
|
||||||
Verify access without displaying credentials:
|
Put the selected series' latest locally available compatible patch first on
|
||||||
|
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build
|
||||||
|
ReleaseKit and run its child validation commands. Set local toolchain selection
|
||||||
|
and verify it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git remote get-url origin
|
export GOTOOLCHAIN=local
|
||||||
git ls-remote origin HEAD
|
go version
|
||||||
|
go env GOVERSION
|
||||||
|
command -v go
|
||||||
```
|
```
|
||||||
|
|
||||||
The fetch URL and all configured push destinations must resolve to the
|
Only when a minor or major release advances the consumer minimum to a newer Go
|
||||||
authoritative URL above. The publication command checks this again. Acquire the
|
series, derive that series' initial stable release from the selected executable:
|
||||||
selected toolchain and dependencies explicitly before offline validation:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
selected_go=$(GOTOOLCHAIN=local go env GOVERSION)
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
selected_series=${selected_go#go}
|
||||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
selected_series=${selected_series%.*}
|
||||||
|
release_minimum="$selected_series.0"
|
||||||
|
go mod edit -go="$release_minimum"
|
||||||
```
|
```
|
||||||
|
|
||||||
A maintained patch may replace the example pin. No per-release latest-version
|
If a dependency or identified correctness fix requires a higher patch minimum,
|
||||||
survey is required. Dependencies must be cached; the checker sets `GOPROXY=off`.
|
use that required version instead and document the reason. When the series is
|
||||||
Publication requires user authorization. Standing authorization for local
|
unchanged, leave the minimum unchanged unless a minor or major release
|
||||||
commits does not authorize a push or release.
|
deliberately changes it under the
|
||||||
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
For example, validating with Go 1.26.8 leaves `go 1.26.0` unchanged. There is no
|
||||||
|
separate requirement to validate with Go 1.26.0.
|
||||||
|
|
||||||
## 2. Select the version and review compatibility
|
Prepare dependencies whether or not the minimum changes:
|
||||||
|
|
||||||
Choose an unused stable version. Compare the candidate with the previous tag.
|
```sh
|
||||||
Review exported Go signatures and ownership, YAML and scalar semantics,
|
go mod download
|
||||||
precedence, diagnostic codes, minimum Go version, and platform support. A
|
```
|
||||||
signature-preserving change may still affect consumers. Before `v1.0.0`,
|
|
||||||
intentional incompatible minor-release changes need a note explaining impact
|
|
||||||
and consumer action; patch releases remain compatible within their minor line.
|
|
||||||
Review the maintained consumer examples and migration guide.
|
|
||||||
|
|
||||||
When dependencies change, review parser behavior and migration impact as part
|
Build the tool selected by `go.mod` into temporary storage outside the checkout.
|
||||||
of that change. Module tidiness belongs to dependency-change review, not the
|
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
||||||
release checker. Do not repeat a dependency-version survey for every release.
|
commands never install another ReleaseKit version:
|
||||||
|
|
||||||
## 3. Write and commit the release note
|
```sh
|
||||||
|
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
|
||||||
|
releasekit_bin="$releasekit_tmp/releasekit"
|
||||||
|
go build -o "$releasekit_bin" gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||||
|
go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
"$releasekit_bin" version
|
||||||
|
```
|
||||||
|
|
||||||
Create `docs/releases/<version>.md` for the selected version. Its first line
|
The module-selected executable is ReleaseKit v0.3.0. Use the built executable
|
||||||
must be `# ConfigKit <version>`. Give a meaningful summary and include
|
for publication to preserve its exit status and publication-state diagnostics.
|
||||||
compatibility impact or upgrade instructions when applicable. Link to current
|
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||||
contracts for exact behavior. Review the note and affected canonical documents
|
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||||
as ordinary preparation, then commit the complete candidate.
|
executable first on `PATH` through the publication command.
|
||||||
|
|
||||||
End every new note with these links, replacing `VERSION` with the complete tag:
|
Verify access to the authoritative remote without displaying credentials:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Publication requires explicit authorization. Local implementation commits do
|
||||||
|
not authorize a push or release.
|
||||||
|
|
||||||
|
## 2. Prepare and commit the candidate
|
||||||
|
|
||||||
|
Choose an unused stable version locally and on the authoritative remote. Compare
|
||||||
|
the candidate with the previous tag and review exported Go signatures and
|
||||||
|
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go
|
||||||
|
version, and platform support. Review maintained consumer examples and the
|
||||||
|
migration guidance. Explain any consumer-minimum increase and required consumer
|
||||||
|
action under the
|
||||||
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
|
||||||
|
Create `docs/releases/<version>.md`. Its first line must be exactly
|
||||||
|
`# ConfigKit <version>`, and it must end with the license and attribution footer:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Confirm the three target files exist in the candidate and review the version in
|
Replace `VERSION` with the full tag and confirm the three target files exist in
|
||||||
the URLs. The future tag need not resolve remotely yet; this review adds no
|
the candidate. Retire completed roadmaps, preserve unfinished and deferred
|
||||||
network gate. ConfigKit uses checked-in notes without hosted release objects.
|
work, and repair affected links. Review the complete candidate for unintended
|
||||||
If a hosted page is used later, reuse the note and preserve its footer. Do not
|
files and credentials, then commit it on `main`. These are ordinary preparation
|
||||||
rewrite historical notes or add downloadable license assets.
|
tasks, not separate approval gates. Any `go.mod` change belongs in this
|
||||||
|
candidate.
|
||||||
|
|
||||||
|
## 3. Push the candidate to `main`
|
||||||
|
|
||||||
|
Push only that committed candidate to the literal authoritative URL. This does
|
||||||
|
not follow local tags:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main
|
||||||
|
```
|
||||||
|
|
||||||
|
ReleaseKit requires authoritative `main` to equal this candidate before it
|
||||||
|
validates. A failure before this push means ReleaseKit publication has not
|
||||||
|
started. If the push result is uncertain, inspect authoritative `main` and
|
||||||
|
continue only when it is confirmed at the candidate. If validation later fails,
|
||||||
|
no release tag is published; correct the issue in a new commit, push the new
|
||||||
|
candidate to `main`, and invoke validation again.
|
||||||
|
|
||||||
## 4. Publish the committed candidate
|
## 4. Publish the committed candidate
|
||||||
|
|
||||||
Before publication, review compatibility, release-note accuracy, and the file
|
With the authorized candidate already on authoritative `main`, invoke the
|
||||||
list for unintended content or credentials. Retire completed roadmaps as part of
|
module-selected native executable exactly once:
|
||||||
release preparation, preserving unfinished and deferred work and repairing
|
|
||||||
links. These are ordinary preparation tasks, not separate human sign-offs.
|
|
||||||
|
|
||||||
With the release explicitly authorized, run the one publication command.
|
|
||||||
The script resolves the repository root from its own path, so it may be invoked
|
|
||||||
from another directory by using its path there. Replace the sample version with
|
|
||||||
the selected version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release.sh v0.5.0
|
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
The command requires `main`, a clean index/worktree, an unused version locally
|
Replace the placeholder with the selected version. ReleaseKit checks the
|
||||||
and on the authoritative remote, and a matching note in the captured commit.
|
effective push URL against `expected_push_url`, verifies the candidate on
|
||||||
Ignored local files do not block it. It checks remote ancestry, runs
|
`main`, and checks the release-note H1 for the product and version. It then
|
||||||
[`release-check.sh`](../scripts/release-check.sh) once against that commit,
|
runs the selected race-enabled standard suite once with test-time vet disabled,
|
||||||
rechecks candidate and version state, pushes only that commit to `main`, then
|
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
||||||
creates and pushes one annotated tag. The tag message is the checked-in note.
|
arm64. It publishes only the annotated tag for that candidate. Confirmed
|
||||||
It never commits or repairs the checkout.
|
publication of the intended tag completes the release. The tag points to the
|
||||||
|
captured commit and uses the complete checked-in note as its annotation message.
|
||||||
A successful intended tag push completes publication. The command reports the
|
The optional `check`
|
||||||
version and commit immediately. No CI, module retrieval, remote read-back, or
|
command is available for diagnosis but is not a separate required pass:
|
||||||
hosted release object is required. The checker may be run separately for
|
|
||||||
diagnosis, but it is not a prerequisite: a fresh publication invocation
|
|
||||||
validates its candidate once.
|
|
||||||
|
|
||||||
## 5. Recover from a failed or uncertain push
|
|
||||||
|
|
||||||
The command performs one bounded authoritative-state check after a failed push.
|
|
||||||
If the main push failed but authoritative main equals the candidate, it proceeds.
|
|
||||||
If the tag push failed but its exact tag object reached the remote, it reports
|
|
||||||
success. Otherwise it reports branch-only, conflicting, or unknown state and
|
|
||||||
retains any local tag.
|
|
||||||
|
|
||||||
Inspect exact refs before any retry. Set `release_version` to the attempted
|
|
||||||
version and compare main with the candidate from the command's report:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.5.0
|
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
||||||
authoritative=https://gitea.maximumdirect.net/eric/ConfigKit.git
|
|
||||||
if remote_main=$(git ls-remote --refs --exit-code "$authoritative" refs/heads/main); then
|
|
||||||
printf 'remote main: %s\n' "$remote_main"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
if [ "$result" -eq 2 ]; then
|
|
||||||
printf 'authoritative main confirmed absent\n' >&2
|
|
||||||
else
|
|
||||||
printf 'remote main state unknown; inspect before retrying\n' >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
ref="refs/tags/$release_version"
|
|
||||||
if remote_tag=$(git ls-remote --refs --exit-code "$authoritative" "$ref"); then
|
|
||||||
printf 'remote tag: %s\n' "$remote_tag"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
if [ "$result" -eq 2 ]; then
|
|
||||||
printf 'remote tag confirmed absent\n'
|
|
||||||
else
|
|
||||||
printf 'remote tag state unknown; do not reuse the version\n' >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if local_tag=$(git rev-parse --verify "refs/tags/$release_version" 2>/dev/null); then
|
|
||||||
printf 'local tag object: %s\n' "$local_tag"
|
|
||||||
else
|
|
||||||
printf 'no local tag object\n'
|
|
||||||
fi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A query failure is not proof that the tag is absent. Compare the remote tag
|
Status 0 confirms publication, status 1 reports a known publication failure or
|
||||||
object ID with the local annotated tag object ID; its peeled commit alone is
|
conflict, status 2 reports usage, profile, or version errors, and status 3 means
|
||||||
insufficient to establish identical publication. A conflicting published tag
|
the push outcome remains unknown. Preserve both the native exit status and its
|
||||||
must not be moved or replaced. If remote main advanced but the tag was not
|
diagnostics.
|
||||||
published, report that partial progress. Only after confirming the local tag
|
|
||||||
was never published or used by another channel may a maintainer remove it:
|
The command does not push or repair `main`, wait for CI, or publish binaries or
|
||||||
|
a hosted release page. A successful intended-tag publication does not require
|
||||||
|
remote read-back or consumer retrieval; those checks remain optional.
|
||||||
|
|
||||||
|
## 5. Recover from a failed or uncertain tag push
|
||||||
|
|
||||||
|
The executable reports the publication state, phase, version, candidate commit,
|
||||||
|
tag-object ID, effective endpoint, and error on standard error. It performs one
|
||||||
|
bounded authoritative reconciliation after a failed tag push. Exit status 3
|
||||||
|
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
||||||
|
|
||||||
|
For an uncertain result, use the exact endpoint, candidate commit, version, and
|
||||||
|
tag-object ID from the report. Copy the captured endpoint into the variable
|
||||||
|
below using normal shell quoting. Check that Git still resolves it to itself,
|
||||||
|
then query the authoritative refs once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
authoritative='COPY_CAPTURED_ENDPOINT'
|
||||||
|
release_version='vMAJOR.MINOR.PATCH'
|
||||||
|
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
|
||||||
|
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
|
||||||
|
```
|
||||||
|
|
||||||
|
A failed URL-equality check or remote query leaves the outcome unresolved.
|
||||||
|
Correct transport configuration before continuing; a rewritten destination
|
||||||
|
cannot establish absence at the captured endpoint.
|
||||||
|
|
||||||
|
Compare the `main` object ID with the reported candidate commit and the tag
|
||||||
|
object ID with the reported tag-object ID. `--refs` returns the annotated tag
|
||||||
|
object itself, not merely its peeled commit. If the tag object matches, the
|
||||||
|
release is published. If the query fails, the result is conflicting, or the
|
||||||
|
endpoint or candidate differs, stop and resolve the state before retrying. If
|
||||||
|
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
||||||
|
the reported candidate, remove a retained local tag only after confirming it
|
||||||
|
was not published or used by another channel; then rerun `publish`, which
|
||||||
|
revalidates the candidate:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag -d "$release_version"
|
git tag -d "$release_version"
|
||||||
|
"$releasekit_bin" publish "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
A subsequent invocation revalidates the candidate; a correctly published tag
|
A validation failure after the candidate reached `main` leaves an untagged
|
||||||
needs no rerun. Do not roll back main or automatically delete a tag. Transport
|
candidate there. Correct it with a new commit rather than rewriting or rolling
|
||||||
or authentication failure alone does not require a new version. A defect in
|
back `main`. A conflicting published tag must never be moved or reused. A
|
||||||
published source requires a corrected commit, a new semantically appropriate
|
defect in published source requires a corrected commit, a new semantically
|
||||||
version and note, and the release procedure again.
|
appropriate version, and a matching release note.
|
||||||
|
|
||||||
## 6. Optional consumer verification
|
## 6. Optional consumer verification
|
||||||
|
|
||||||
Only when useful or requested, read back the remote tag or retrieve the exact
|
When useful or requested, read back the remote tag or retrieve the exact
|
||||||
version into a new module without a local `replace` directive. These checks
|
version into a new module without a local `replace` directive. These checks are
|
||||||
are optional and non-gating. Do not poll CI or wait for module propagation.
|
optional and non-gating. Do not poll CI or wait for module propagation. For
|
||||||
Private-host credentials must already be configured. For direct-origin
|
direct-origin verification, use the exact published version in a temporary
|
||||||
verification, set the version to the release just published (replace the sample
|
consumer module:
|
||||||
below), then use a temporary consumer module and check the exact module path
|
|
||||||
and version reported by `go list -m`:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.5.0
|
release_version=vMAJOR.MINOR.PATCH
|
||||||
consumer_dir=$(mktemp -d)
|
consumer_dir=$(mktemp -d)
|
||||||
(
|
(
|
||||||
cd "$consumer_dir"
|
cd "$consumer_dir"
|
||||||
|
|||||||
63
docs/releases/v0.6.0.md
Normal file
63
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ConfigKit v0.6.0
|
||||||
|
|
||||||
|
This minor release reduces avoidable configuration-loading and inspection work,
|
||||||
|
adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
||||||
|
1.26.0. The public configuration model remains unchanged from v0.5.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Reduce temporary path allocations by sizing pointer-formatting buffers,
|
||||||
|
combining final metadata traversal, reusing parsed sensitivity paths, and
|
||||||
|
avoiding path construction when detecting inheritance. Effective and
|
||||||
|
historical metadata indexes retain independently owned paths.
|
||||||
|
- Replace repeated resolution-coverage, metadata-update, CLI-overlap, and
|
||||||
|
sensitivity-propagation scans with private indexes and incremental processing.
|
||||||
|
Preserve diagnostic ordering, history, contextual path shapes, and redaction.
|
||||||
|
- Make presence-only metadata queries avoid materializing provenance. Prepare
|
||||||
|
resolution replay descriptors and immutable mapping and inheritance-history
|
||||||
|
lookups for inspection, and use ordered membership indexes for provenance
|
||||||
|
collections. Returned records remain detached; explanations still replay
|
||||||
|
complete trees and render on demand.
|
||||||
|
- Add a maintained public-API startup construction benchmark. The initial path
|
||||||
|
formatting and metadata traversal experiments reproduced allocation reductions
|
||||||
|
across local and downstream workloads. Later algorithmic improvements were
|
||||||
|
validated behaviorally without a new benchmark campaign. Busy-host timings
|
||||||
|
were inconclusive; no fixed speedup or allocation threshold is promised.
|
||||||
|
- Replace ConfigKit-owned release scripts with a small profile and the
|
||||||
|
module-selected ReleaseKit v0.3.0 tool. The
|
||||||
|
[release procedure](../release.md) documents candidate preparation, tag-only
|
||||||
|
publication, and failed-push recovery. Existing release checks and the
|
||||||
|
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||||
|
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||||
|
validation toolchain. Retire completed planning and measurement records into
|
||||||
|
Git history while preserving [deferred work](../roadmap/deferred.md).
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
The consumer minimum increases from Go 1.25.0 to **Go 1.26.0**. Install Go 1.26.0
|
||||||
|
or newer before upgrading, and update application build and CI environments that
|
||||||
|
still use Go 1.25. The `go.mod` directive records the consumer minimum; using a
|
||||||
|
newer validation patch does not itself raise that minimum. See the
|
||||||
|
[consumer minimum policy](../policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
No application-code or configuration migration is required from v0.5.0. Public
|
||||||
|
Go signatures, strict within-tier composition, recursive tier precedence,
|
||||||
|
sibling inheritance, presence/null distinctions, provenance, secret handling,
|
||||||
|
and synchronous completion retain their existing contracts. See the
|
||||||
|
[configuration contract](../config.md) and
|
||||||
|
[consumer guide](../consumers/README.md).
|
||||||
|
|
||||||
|
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`. ReleaseKit is a
|
||||||
|
declared Go tool dependency for maintainer commands, not a library runtime
|
||||||
|
import; its requirements participate in ConfigKit's module graph. Linux amd64
|
||||||
|
and arm64 remain release-validated targets, and macOS remains best-effort.
|
||||||
|
Maintainers should use the current release procedure instead of the retired
|
||||||
|
`scripts/release.sh` and `scripts/release-check.sh` commands.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/docs/oss/attribution.md)
|
||||||
@@ -32,3 +32,20 @@ concrete consumer evidence, compatibility review, and an explicit design change.
|
|||||||
Built-in sibling inheritance using `extends` is implemented as described in the
|
Built-in sibling inheritance using `extends` is implemented as described in the
|
||||||
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
||||||
inheritance between selected overlay files, which remains excluded above.
|
inheritance between selected overlay files, which remains excluded above.
|
||||||
|
|
||||||
|
## Deferred performance experiments
|
||||||
|
|
||||||
|
The completed performance audit left these internal optimization candidates for
|
||||||
|
separate investigation:
|
||||||
|
|
||||||
|
- branch-local explanation replay instead of the current whole-tree replay;
|
||||||
|
- redesigning the private metadata-copy handoff while preserving detached public
|
||||||
|
records;
|
||||||
|
- caching schema descriptors across loads; and
|
||||||
|
- indexing complex contributor-trace membership while preserving its equality
|
||||||
|
rules and compact linked traces.
|
||||||
|
|
||||||
|
These are not committed implementation work. Reconsider them with evidence that
|
||||||
|
the benefit justifies the ownership, retained-memory, or equivalence risks. The
|
||||||
|
completed audit and implementation evidence are preserved in Git history at
|
||||||
|
`8e91a2b` under `docs/roadmap/`.
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -1,5 +1,13 @@
|
|||||||
module gitea.maximumdirect.net/eric/ConfigKit
|
module gitea.maximumdirect.net/eric/ConfigKit
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 // indirect
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,2 +1,8 @@
|
|||||||
|
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 h1:aTMn+50MEpHpHI2RoCCmJYsy7Ohrpa+RSva63Kq0/tU=
|
||||||
|
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||||
|
|||||||
@@ -85,15 +85,28 @@ func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
|||||||
for i := range canonical {
|
for i := range canonical {
|
||||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||||
}
|
}
|
||||||
slices.SortFunc(canonical, compareDiagnostic)
|
type orderedDiagnostic struct {
|
||||||
|
diagnostic Diagnostic
|
||||||
|
pointer string
|
||||||
|
}
|
||||||
|
ordered := make([]orderedDiagnostic, len(canonical))
|
||||||
|
for i, diagnostic := range canonical {
|
||||||
|
ordered[i] = orderedDiagnostic{diagnostic: diagnostic, pointer: diagnostic.Path.String()}
|
||||||
|
}
|
||||||
|
slices.SortFunc(ordered, func(left, right orderedDiagnostic) int {
|
||||||
|
return compareDiagnosticWithPaths(left.diagnostic, right.diagnostic, left.pointer, right.pointer)
|
||||||
|
})
|
||||||
|
for i, item := range ordered {
|
||||||
|
canonical[i] = item.diagnostic
|
||||||
|
}
|
||||||
return canonical
|
return canonical
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareDiagnostic(left, right Diagnostic) int {
|
func compareDiagnosticWithPaths(left, right Diagnostic, leftPath, rightPath string) int {
|
||||||
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
if order := cmp.Compare(leftPath, rightPath); order != 0 {
|
||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
||||||
|
|||||||
@@ -90,6 +90,26 @@ func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalDiagnosticsSortEscapedPaths(t *testing.T) {
|
||||||
|
primary := &Location{SourceID: "source-1", Line: 1, Column: 1}
|
||||||
|
diagnostics := []Diagnostic{
|
||||||
|
{Code: "same", Severity: SeverityError, Path: Keys("a/b"), Message: "same", Primary: primary},
|
||||||
|
{Code: "same", Severity: SeverityError, Path: Keys("a~b"), Message: "same", Primary: primary},
|
||||||
|
{Code: "z", Severity: SeverityError, Path: Keys("same"), Message: "same", Primary: primary},
|
||||||
|
{Code: "a", Severity: SeverityError, Path: Keys("same"), Message: "same", Primary: primary},
|
||||||
|
}
|
||||||
|
canonical := canonicalDiagnostics(diagnostics)
|
||||||
|
want := []string{"/a~0b", "/a~1b", "/same", "/same"}
|
||||||
|
for i, diagnostic := range canonical {
|
||||||
|
if got := diagnostic.Path.String(); got != want[i] {
|
||||||
|
t.Fatalf("diagnostic[%d] path = %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canonical[2].Code != "a" || canonical[3].Code != "z" {
|
||||||
|
t.Fatalf("same-path code order = %q, %q", canonical[2].Code, canonical[3].Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,23 @@ type documentDelta struct {
|
|||||||
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||||
var deltas []documentDelta
|
var deltas []documentDelta
|
||||||
compareConfigurationValue(before, after, Path{}, &deltas)
|
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||||
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
type orderedDelta struct {
|
||||||
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
|
delta documentDelta
|
||||||
|
pointer string
|
||||||
|
}
|
||||||
|
ordered := make([]orderedDelta, len(deltas))
|
||||||
|
for i, delta := range deltas {
|
||||||
|
ordered[i] = orderedDelta{delta: delta, pointer: delta.path.String()}
|
||||||
|
}
|
||||||
|
slices.SortFunc(ordered, func(left, right orderedDelta) int {
|
||||||
|
if order := cmp.Compare(left.pointer, right.pointer); order != 0 {
|
||||||
return order
|
return order
|
||||||
}
|
}
|
||||||
return int(left.kind) - int(right.kind)
|
return int(left.delta.kind) - int(right.delta.kind)
|
||||||
})
|
})
|
||||||
|
for i, item := range ordered {
|
||||||
|
deltas[i] = item.delta
|
||||||
|
}
|
||||||
return deltas
|
return deltas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
internal/engine/document_lookup.go
Normal file
54
internal/engine/document_lookup.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
// documentLookup contains read-only mapping positions for a completed document.
|
||||||
|
type documentLookup struct {
|
||||||
|
byNode map[*document.Node]map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDocumentLookup(roots ...*document.Node) *documentLookup {
|
||||||
|
lookup := &documentLookup{byNode: make(map[*document.Node]map[string]int)}
|
||||||
|
visited := make(map[*document.Node]struct{})
|
||||||
|
var visit func(*document.Node)
|
||||||
|
visit = func(node *document.Node) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := visited[node]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visited[node] = struct{}{}
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
entries := make(map[string]int, len(node.Mapping))
|
||||||
|
for index, entry := range node.Mapping {
|
||||||
|
if _, exists := entries[entry.Key]; !exists {
|
||||||
|
entries[entry.Key] = index
|
||||||
|
}
|
||||||
|
visit(entry.Value)
|
||||||
|
}
|
||||||
|
lookup.byNode[node] = entries
|
||||||
|
case document.Sequence:
|
||||||
|
for _, child := range node.Sequence {
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, root := range roots {
|
||||||
|
visit(root)
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup *documentLookup) find(node *document.Node, key string) int {
|
||||||
|
if lookup == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if entries := lookup.byNode[node]; entries != nil {
|
||||||
|
if index, ok := entries[key]; ok {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ func indexDocument(node *document.Node, path Path, index map[string]*document.No
|
|||||||
}
|
}
|
||||||
|
|
||||||
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
||||||
|
return documentAtPathWithLookup(root, path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentAtPathWithLookup(root *document.Node, path Path, lookup *documentLookup) (*document.Node, bool) {
|
||||||
if root == nil || !path.valid() {
|
if root == nil || !path.valid() {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -41,17 +45,21 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
|||||||
if segment.kind == pathSegmentIndex {
|
if segment.kind == pathSegmentIndex {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
found := false
|
entryIndex := -1
|
||||||
for _, entry := range current.Mapping {
|
if lookup != nil {
|
||||||
if entry.Key == segment.key {
|
entryIndex = lookup.find(current, segment.key)
|
||||||
current = entry.Value
|
} else {
|
||||||
found = true
|
for index, entry := range current.Mapping {
|
||||||
break
|
if entry.Key == segment.key {
|
||||||
|
entryIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if entryIndex < 0 || entryIndex >= len(current.Mapping) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
current = current.Mapping[entryIndex].Value
|
||||||
case document.Sequence:
|
case document.Sequence:
|
||||||
index, ok := schemaIndex(segment)
|
index, ok := schemaIndex(segment)
|
||||||
if !ok || index >= len(current.Sequence) {
|
if !ok || index >= len(current.Sequence) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
||||||
return Explanation{}, false
|
return Explanation{}, false
|
||||||
}
|
}
|
||||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, r.immutableLookup)}
|
||||||
if info, ok := r.metadata.Lookup(path); ok {
|
if info, ok := r.metadata.Lookup(path); ok {
|
||||||
explanation.Origin = cloneOrigin(info.Origin)
|
explanation.Origin = cloneOrigin(info.Origin)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
}
|
}
|
||||||
replayIndex := new(mappingIndex)
|
replayIndex := new(mappingIndex)
|
||||||
for _, application := range r.applications {
|
for _, application := range r.applications {
|
||||||
operationPath, relevant := applicationOperationPath(application, path)
|
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
|
||||||
var before ValueSnapshot
|
var before ValueSnapshot
|
||||||
if relevant {
|
if relevant {
|
||||||
before = valueSnapshot(working, path, r.secrets)
|
before = valueSnapshot(working, path, r.secrets)
|
||||||
@@ -69,7 +69,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: before,
|
Before: before,
|
||||||
After: after,
|
After: after,
|
||||||
Origin: historyApplicationOrigin(application, operationPath),
|
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets)
|
appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets)
|
||||||
@@ -78,20 +78,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
|
|||||||
// decoding applies them to the final input tree. Show that reconciliation
|
// decoding applies them to the final input tree. Show that reconciliation
|
||||||
// explicitly, including canonical scalar conversions, before domain changes.
|
// explicitly, including canonical scalar conversions, before domain changes.
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, path)
|
||||||
decoded, _ := documentAtPath(r.input, path)
|
decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
|
||||||
if !configurationValuesEqual(replayed, decoded) {
|
if !configurationValuesEqual(replayed, decoded) {
|
||||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||||
origin.Operation = ChangeDecoding
|
origin.Operation = ChangeDecoding
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: valueSnapshot(working, path, r.secrets),
|
Before: valueSnapshot(working, path, r.secrets),
|
||||||
After: valueSnapshot(r.input, path, r.secrets),
|
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
working = document.Clone(r.input)
|
working = document.Clone(r.input)
|
||||||
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
for _, snapshot := range r.resolutionReplay {
|
||||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||||
var before ValueSnapshot
|
var before ValueSnapshot
|
||||||
if relevant {
|
if relevant {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
@@ -520,3 +521,95 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{
|
||||||
|
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||||
|
value.Count = 2
|
||||||
|
resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count"))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, ok := result.Explain(Keys("count"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
var firstResolution *ValueChange
|
||||||
|
for index := range first.History {
|
||||||
|
if first.History[index].Origin.Layer == LayerResolution {
|
||||||
|
firstResolution = &first.History[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstResolution == nil || len(firstResolution.Reasons) != 1 || len(firstResolution.Dependencies) != 1 {
|
||||||
|
t.Fatalf("resolution history = %#v", first.History)
|
||||||
|
}
|
||||||
|
firstResolution.Reasons[0] = "mutated"
|
||||||
|
firstResolution.Dependencies[0].segments[0].key = "mutated"
|
||||||
|
|
||||||
|
second, ok := result.Explain(Keys("count"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing repeated explanation")
|
||||||
|
}
|
||||||
|
for _, change := range second.History {
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
if !reflect.DeepEqual(change.Reasons, []string{"normalized"}) || len(change.Dependencies) != 1 || change.Dependencies[0].String() != "/count" {
|
||||||
|
t.Fatalf("repeated explanation observed caller mutation: %#v", change)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("resolution change missing from repeated explanation: %#v", second.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentReadOnlyInspectionQueries(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Group struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
} `yaml:"group"`
|
||||||
|
}
|
||||||
|
result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{
|
||||||
|
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
|
||||||
|
value.Group.Value = "resolved"
|
||||||
|
resolution.Record(Keys("group", "value"), ChangeNormalization, "normalized", Keys("group", "value"))
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths := []Path{Keys("group"), Keys("group", "value")}
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
errors := make(chan string, 8)
|
||||||
|
for worker := 0; worker < cap(errors); worker++ {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
for iteration := 0; iteration < 8; iteration++ {
|
||||||
|
for _, path := range paths {
|
||||||
|
explanation, found := result.Explain(path)
|
||||||
|
if !found || !explanation.Effective.Present {
|
||||||
|
errors <- "missing effective explanation for " + path.String()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
if _, found := metadata.Lookup(path); !found || len(metadata.History(path)) == 0 {
|
||||||
|
errors <- "missing metadata for " + path.String()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wait.Wait()
|
||||||
|
close(errors)
|
||||||
|
for message := range errors {
|
||||||
|
t.Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
|
||||||
if !application.path.valid() || application.value == nil {
|
if !application.path.valid() || application.value == nil {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
|
|||||||
}
|
}
|
||||||
for length := 1; length <= len(query.segments); length++ {
|
for length := 1; length <= len(query.segments); length++ {
|
||||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||||
node, present := documentAtPath(application.value, prefix)
|
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
||||||
if !present {
|
if !present {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
|
|||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
func historyApplicationOrigin(application historyApplication, operationPath Path, lookup *documentLookup) Origin {
|
||||||
if application.origin.Layer != "" {
|
if application.origin.Layer != "" {
|
||||||
return cloneOrigin(application.origin)
|
return cloneOrigin(application.origin)
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
|
|||||||
if !ok {
|
if !ok {
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
if node, present := documentAtPath(application.value, relative); present {
|
if node, present := documentAtPathWithLookup(application.value, relative, lookup); present {
|
||||||
origin.Locations = []Location{publicLocation(node.Location)}
|
origin.Locations = []Location{publicLocation(node.Location)}
|
||||||
}
|
}
|
||||||
return origin
|
return origin
|
||||||
@@ -179,28 +179,50 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
|
|||||||
// intent into that event rather than replay a child assignment a second time.
|
// intent into that event rather than replay a child assignment a second time.
|
||||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||||
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||||
|
reasons := make([]map[string]struct{}, 0, len(snapshots))
|
||||||
|
dependencies := make([]map[string]struct{}, 0, len(snapshots))
|
||||||
|
var coverage pathPrefixIndex
|
||||||
for _, snapshot := range snapshots {
|
for _, snapshot := range snapshots {
|
||||||
merged := false
|
pointer := snapshot.change.Path.String()
|
||||||
for i := range groups {
|
groupIndex := -1
|
||||||
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
coverage.visitAncestors(pointer, func(id int) {
|
||||||
continue
|
if groupIndex < 0 || id < groupIndex {
|
||||||
|
groupIndex = id
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
if groupIndex >= 0 {
|
||||||
|
group := &groups[groupIndex]
|
||||||
for _, reason := range snapshot.change.Reasons {
|
for _, reason := range snapshot.change.Reasons {
|
||||||
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
if _, found := reasons[groupIndex][reason]; !found {
|
||||||
|
reasons[groupIndex][reason] = struct{}{}
|
||||||
|
group.change.Reasons = append(group.change.Reasons, reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, dependency := range snapshot.change.Dependencies {
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
dependencyPointer := dependency.String()
|
||||||
|
if _, found := dependencies[groupIndex][dependencyPointer]; !found {
|
||||||
|
dependencies[groupIndex][dependencyPointer] = struct{}{}
|
||||||
|
group.change.Dependencies = append(group.change.Dependencies, dependency.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if snapshot.change.Origin.Operation == ChangeNormalization {
|
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||||
groups[i].change.Origin.Operation = ChangeNormalization
|
group.change.Origin.Operation = ChangeNormalization
|
||||||
}
|
}
|
||||||
merged = true
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if !merged {
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
snapshot.change = cloneChange(snapshot.change)
|
groups = append(groups, snapshot)
|
||||||
groups = append(groups, snapshot)
|
reasonSet := make(map[string]struct{}, len(snapshot.change.Reasons))
|
||||||
|
for _, reason := range snapshot.change.Reasons {
|
||||||
|
reasonSet[reason] = struct{}{}
|
||||||
}
|
}
|
||||||
|
reasons = append(reasons, reasonSet)
|
||||||
|
dependencySet := make(map[string]struct{}, len(snapshot.change.Dependencies))
|
||||||
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
|
dependencySet[dependency.String()] = struct{}{}
|
||||||
|
}
|
||||||
|
dependencies = append(dependencies, dependencySet)
|
||||||
|
coverage.insert(pointer, len(groups)-1)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,57 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inheritanceHistoryIndex struct {
|
||||||
|
paths pathPrefixIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInheritanceHistoryIndex(operations []inheritanceOperation) *inheritanceHistoryIndex {
|
||||||
|
if len(operations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
index := &inheritanceHistoryIndex{}
|
||||||
|
for operationID, operation := range operations {
|
||||||
|
if operation.control != nil {
|
||||||
|
index.paths.insert(operation.control.path.String(), operationID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if operation.transfer != nil {
|
||||||
|
index.paths.insert(operation.transfer.to.String(), operationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *inheritanceHistoryIndex) matchingOperations(path Path, operations []inheritanceOperation) []inheritanceOperation {
|
||||||
|
if index == nil {
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
var ids []int
|
||||||
|
seen := make(map[int]struct{})
|
||||||
|
collect := func(id int) {
|
||||||
|
if _, found := seen[id]; found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
pointer := path.String()
|
||||||
|
index.paths.visitAncestors(pointer, collect)
|
||||||
|
index.paths.visitDescendants(pointer, collect)
|
||||||
|
sort.Ints(ids)
|
||||||
|
matching := make([]inheritanceOperation, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id >= 0 && id < len(operations) {
|
||||||
|
matching = append(matching, operations[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matching
|
||||||
|
}
|
||||||
|
|
||||||
// Retain source history at original paths. Structural operations are held once
|
// Retain source history at original paths. Structural operations are held once
|
||||||
// per relationship and projected onto a requested destination path only when
|
// per relationship and projected onto a requested destination path only when
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -311,6 +312,51 @@ func TestInheritanceHistoryRetainsControlsAndDisplacedPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIndexedInheritanceHistoryMatchesOperationScan(t *testing.T) {
|
||||||
|
inspection, _ := inheritanceInspectionForTest(t,
|
||||||
|
parseCompositionDocument(t, "source-1", "parent: {branch: {detail: inherited, retained: present}}\n"),
|
||||||
|
parseCompositionDocument(t, "source-2", "child: {extends: parent, branch: replaced}\n"),
|
||||||
|
parseCompositionDocument(t, "source-3", "sibling: {extends: parent}\n"))
|
||||||
|
metadata := inspection.Metadata()
|
||||||
|
if len(metadata.inheritance) < 4 {
|
||||||
|
t.Fatalf("inheritance operations = %d, want multiple control and transfer scopes", len(metadata.inheritance))
|
||||||
|
}
|
||||||
|
queries := []Path{
|
||||||
|
Keys("child"),
|
||||||
|
Keys("child", inheritanceKey),
|
||||||
|
Keys("child", "branch", "detail"),
|
||||||
|
Keys("child", "branch"),
|
||||||
|
Keys("sibling", "branch", "detail"),
|
||||||
|
Path{},
|
||||||
|
}
|
||||||
|
for _, path := range queries {
|
||||||
|
if !metadata.hasHistoricalPath(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stored := metadata.history[path.String()]
|
||||||
|
want := historyWithInheritance(path, stored, metadata.inheritance)
|
||||||
|
if got := metadata.History(path); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("indexed history for %s differs from operation scan:\n got: %#v\nwant: %#v", path, got, want)
|
||||||
|
}
|
||||||
|
unprepared := Metadata{history: metadata.history, historicalPaths: metadata.historicalPaths, inheritance: metadata.inheritance}
|
||||||
|
if got := unprepared.History(path); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("unprepared history for %s differs from operation scan:\n got: %#v\nwant: %#v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := Keys("child", "branch", "detail")
|
||||||
|
first := metadata.History(removed)
|
||||||
|
if len(first) == 0 || len(first[0].Path.segments) == 0 {
|
||||||
|
t.Fatalf("removed-path history = %#v", first)
|
||||||
|
}
|
||||||
|
first[0].Path.segments[0].key = "mutated"
|
||||||
|
second := cloneMetadata(metadata).History(removed)
|
||||||
|
want := historyWithInheritance(removed, metadata.history[removed.String()], metadata.inheritance)
|
||||||
|
if !reflect.DeepEqual(second, want) {
|
||||||
|
t.Fatalf("returned history mutation affected a repeated or cloned query:\n got: %#v\nwant: %#v", second, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
|
func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
|
||||||
inspection, _ := inheritanceInspectionForTest(t,
|
inspection, _ := inheritanceInspectionForTest(t,
|
||||||
parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n"))
|
parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n"))
|
||||||
@@ -324,6 +370,7 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
|
|||||||
replaceHistoryPath(&final, path, replacement)
|
replaceHistoryPath(&final, path, replacement)
|
||||||
inspection.final = final
|
inspection.final = final
|
||||||
inspection.resolution = []resolutionSnapshot{snapshot}
|
inspection.resolution = []resolutionSnapshot{snapshot}
|
||||||
|
inspection.resolutionReplay = coalescedResolutionSnapshots(inspection.resolution)
|
||||||
inspection.metadata.history[path.String()] = append(inspection.metadata.history[path.String()], snapshot.change)
|
inspection.metadata.history[path.String()] = append(inspection.metadata.history[path.String()], snapshot.change)
|
||||||
inspection.metadata.fields[path.String()] = FieldInfo{Explicit: true, Origin: snapshot.change.Origin}
|
inspection.metadata.fields[path.String()] = FieldInfo{Explicit: true, Origin: snapshot.change.Origin}
|
||||||
history := inspection.Metadata().History(path)
|
history := inspection.Metadata().History(path)
|
||||||
|
|||||||
@@ -3,14 +3,28 @@ package engine
|
|||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
func containsInheritanceDirective(root *document.Node) bool {
|
func containsInheritanceDirective(root *document.Node) bool {
|
||||||
found := false
|
var visit func(*document.Node) bool
|
||||||
walkDocument(root, Path{}, func(path Path, _ *document.Node) {
|
visit = func(node *document.Node) bool {
|
||||||
if len(path.segments) != 0 && path.segments[len(path.segments)-1].kind == pathSegmentKey &&
|
if node == nil {
|
||||||
path.segments[len(path.segments)-1].key == inheritanceKey {
|
return false
|
||||||
found = true
|
|
||||||
}
|
}
|
||||||
})
|
switch node.Kind {
|
||||||
return found
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key == inheritanceKey || visit(entry.Value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for _, child := range node.Sequence {
|
||||||
|
if visit(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return visit(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic {
|
func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
@@ -74,10 +75,44 @@ func copyInheritanceSubtree(target, incoming map[string]Origin, node *document.N
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type inheritanceStepKey struct {
|
||||||
|
from string
|
||||||
|
to string
|
||||||
|
reference string
|
||||||
|
locations string
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceStepKeyForPaths(from, to, reference Path, locations []Location) inheritanceStepKey {
|
||||||
|
return inheritanceStepKey{
|
||||||
|
from: from.String(), to: to.String(), reference: reference.String(),
|
||||||
|
locations: inheritanceLocationsKey(locations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceLocationsKey(locations []Location) string {
|
||||||
|
encoded := strconv.AppendInt(nil, int64(len(locations)), 10)
|
||||||
|
encoded = append(encoded, ';')
|
||||||
|
for _, location := range locations {
|
||||||
|
encoded = strconv.AppendInt(encoded, int64(len(location.SourceID)), 10)
|
||||||
|
encoded = append(encoded, ':')
|
||||||
|
encoded = append(encoded, location.SourceID...)
|
||||||
|
encoded = append(encoded, ';')
|
||||||
|
encoded = strconv.AppendInt(encoded, int64(location.Line), 10)
|
||||||
|
encoded = append(encoded, ';')
|
||||||
|
encoded = strconv.AppendInt(encoded, int64(location.Column), 10)
|
||||||
|
encoded = append(encoded, ';')
|
||||||
|
}
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
// Materialize concrete steps only for an inspection record. The linked private
|
// Materialize concrete steps only for an inspection record. The linked private
|
||||||
// traces remain compact when one parent supplies many descendant values.
|
// traces remain compact when one parent supplies many descendant values.
|
||||||
func projectInheritanceSteps(origin Origin) []InheritanceStep {
|
func projectInheritanceSteps(origin Origin) []InheritanceStep {
|
||||||
steps := cloneInheritanceSteps(origin.Inheritance)
|
steps := cloneInheritanceSteps(origin.Inheritance)
|
||||||
|
seen := make(map[inheritanceStepKey]struct{}, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
seen[inheritanceStepKeyForPaths(step.From, step.To, step.Reference, step.Locations)] = struct{}{}
|
||||||
|
}
|
||||||
for _, contributor := range origin.contributors {
|
for _, contributor := range origin.contributors {
|
||||||
var reverse []*inheritanceTrace
|
var reverse []*inheritanceTrace
|
||||||
for link := contributor.trace; link != nil; link = link.previous {
|
for link := contributor.trace; link != nil; link = link.previous {
|
||||||
@@ -91,17 +126,13 @@ func projectInheritanceSteps(origin Origin) []InheritanceStep {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
next := appendInheritancePath(transfer.to, relative)
|
next := appendInheritancePath(transfer.to, relative)
|
||||||
step := InheritanceStep{
|
key := inheritanceStepKeyForPaths(current, next, transfer.reference, transfer.locations)
|
||||||
From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
|
if _, found := seen[key]; !found {
|
||||||
Locations: cloneLocations(transfer.locations),
|
seen[key] = struct{}{}
|
||||||
}
|
steps = append(steps, InheritanceStep{
|
||||||
if !slices.ContainsFunc(steps, func(existing InheritanceStep) bool {
|
From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
|
||||||
return existing.From.String() == step.From.String() &&
|
Locations: cloneLocations(transfer.locations),
|
||||||
existing.To.String() == step.To.String() &&
|
})
|
||||||
existing.Reference.String() == step.Reference.String() &&
|
|
||||||
slices.Equal(existing.Locations, step.Locations)
|
|
||||||
}) {
|
|
||||||
steps = append(steps, step)
|
|
||||||
}
|
}
|
||||||
current = next
|
current = next
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
@@ -85,6 +87,165 @@ func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOriginMembershipPreservesLocationAndSourceCoverageRules(t *testing.T) {
|
||||||
|
location := Location{SourceID: "source", Line: 4, Column: 2}
|
||||||
|
otherCoordinates := location
|
||||||
|
otherCoordinates.Line++
|
||||||
|
|
||||||
|
container := Origin{
|
||||||
|
Layer: LayerOverlay,
|
||||||
|
Operation: ChangeNormalization,
|
||||||
|
Locations: []Location{location},
|
||||||
|
}
|
||||||
|
if !originContainedBy(container, container) {
|
||||||
|
t.Fatal("identical location evidence was not contained")
|
||||||
|
}
|
||||||
|
coordinateDifference := container
|
||||||
|
coordinateDifference.Locations = []Location{otherCoordinates}
|
||||||
|
if originContainedBy(coordinateDifference, container) {
|
||||||
|
t.Fatal("location containment ignored differing coordinates")
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := Keys("parent")
|
||||||
|
child := parent.Key("child")
|
||||||
|
change := Change{Path: parent, Origin: container}
|
||||||
|
descendantOrigin := Origin{
|
||||||
|
Layer: container.Layer,
|
||||||
|
Operation: container.Operation,
|
||||||
|
Locations: []Location{otherCoordinates},
|
||||||
|
}
|
||||||
|
if !originRepresentedByChange(child, descendantOrigin, change) {
|
||||||
|
t.Fatal("descendant source coverage rejected matching SourceID with different coordinates")
|
||||||
|
}
|
||||||
|
descendantOrigin.Locations[0].SourceID = "another-source"
|
||||||
|
if originRepresentedByChange(child, descendantOrigin, change) {
|
||||||
|
t.Fatal("descendant source coverage accepted an unrecorded SourceID")
|
||||||
|
}
|
||||||
|
|
||||||
|
withContributor := container
|
||||||
|
withContributor.contributors = []inheritanceContributor{{source: parent, location: location}}
|
||||||
|
if originContainedBy(withContributor, container) {
|
||||||
|
t.Fatal("exact location containment ignored the contributor-empty mismatch")
|
||||||
|
}
|
||||||
|
if !originRepresentedByChange(child, withContributor, change) {
|
||||||
|
t.Fatal("descendant source coverage was conflated with contributor containment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectedInheritanceStepsMatchScanAndRetainOwnership(t *testing.T) {
|
||||||
|
sourcePath := Keys("source", "a/b", "01")
|
||||||
|
firstLocations := []Location{{SourceID: "first;:source", Line: -1, Column: 2}}
|
||||||
|
secondLocations := []Location{
|
||||||
|
{SourceID: "second:;source", Line: 3, Column: -4},
|
||||||
|
{SourceID: "", Line: -5, Column: 6},
|
||||||
|
}
|
||||||
|
firstTransfer := inheritanceTransfer{
|
||||||
|
from: Keys("source"), to: Keys("middle"), reference: Keys("middle", "extends"), locations: firstLocations,
|
||||||
|
}
|
||||||
|
secondTransfer := inheritanceTransfer{
|
||||||
|
from: Keys("middle"), to: Keys("target"), reference: Keys("target", "extends"), locations: secondLocations,
|
||||||
|
}
|
||||||
|
firstTrace := &inheritanceTrace{transfer: &firstTransfer}
|
||||||
|
chain := &inheritanceTrace{previous: firstTrace, transfer: &secondTransfer}
|
||||||
|
firstStep := InheritanceStep{
|
||||||
|
From: sourcePath, To: Keys("middle", "a/b", "01"), Reference: firstTransfer.reference, Locations: firstLocations,
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateFirstTransfer := firstTransfer
|
||||||
|
duplicateSecondTransfer := secondTransfer
|
||||||
|
duplicateChain := &inheritanceTrace{
|
||||||
|
previous: &inheritanceTrace{transfer: &duplicateFirstTransfer},
|
||||||
|
transfer: &duplicateSecondTransfer,
|
||||||
|
}
|
||||||
|
reversedLocations := []Location{secondLocations[1], secondLocations[0]}
|
||||||
|
reorderedSecondTransfer := secondTransfer
|
||||||
|
reorderedSecondTransfer.locations = reversedLocations
|
||||||
|
reorderedChain := &inheritanceTrace{
|
||||||
|
previous: &inheritanceTrace{transfer: &duplicateFirstTransfer},
|
||||||
|
transfer: &reorderedSecondTransfer,
|
||||||
|
}
|
||||||
|
origin := Origin{
|
||||||
|
Inheritance: []InheritanceStep{firstStep, firstStep},
|
||||||
|
contributors: []inheritanceContributor{
|
||||||
|
{source: sourcePath, trace: chain},
|
||||||
|
{source: sourcePath, trace: duplicateChain},
|
||||||
|
{source: sourcePath, trace: reorderedChain},
|
||||||
|
{source: sourcePath, trace: chain},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
want := projectInheritanceStepsByScan(origin)
|
||||||
|
got := projectInheritanceSteps(origin)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("projected steps differ from ordered scan:\n got: %#v\nwant: %#v", got, want)
|
||||||
|
}
|
||||||
|
if len(got) != 4 || got[0].From.String() != "/source/a~1b/01" || got[0].To.String() != "/middle/a~1b/01" ||
|
||||||
|
!reflect.DeepEqual(got[0], got[1]) || got[2].To.String() != "/target/a~1b/01" ||
|
||||||
|
!reflect.DeepEqual(got[2].Locations, secondLocations) || !reflect.DeepEqual(got[3].Locations, reversedLocations) {
|
||||||
|
t.Fatalf("unexpected duplicate/order/escape projection: %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got[0].From.segments[0].key = "mutated"
|
||||||
|
got[2].Locations[0].SourceID = "mutated"
|
||||||
|
if repeated := projectInheritanceSteps(origin); !reflect.DeepEqual(repeated, want) {
|
||||||
|
t.Fatalf("returned steps mutated retained evidence:\n got: %#v\nwant: %#v", repeated, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceLocationsKeyEncodesOrderedLengthPrefixedValues(t *testing.T) {
|
||||||
|
if inheritanceLocationsKey(nil) != inheritanceLocationsKey([]Location{}) {
|
||||||
|
t.Fatal("nil and empty location lists should encode equally")
|
||||||
|
}
|
||||||
|
values := [][]Location{
|
||||||
|
{{SourceID: "a;1;-2;", Line: -3, Column: 4}},
|
||||||
|
{{SourceID: "a", Line: 1, Column: -2}, {SourceID: ";1;-2;", Line: -3, Column: 4}},
|
||||||
|
{{SourceID: ";1;-2;", Line: -3, Column: 4}, {SourceID: "a", Line: 1, Column: -2}},
|
||||||
|
{{SourceID: "é", Line: -3, Column: 4}},
|
||||||
|
{{SourceID: "e", Line: -3, Column: 4}},
|
||||||
|
}
|
||||||
|
keys := make(map[string]int, len(values))
|
||||||
|
for index, locations := range values {
|
||||||
|
key := inheritanceLocationsKey(locations)
|
||||||
|
if previous, found := keys[key]; found {
|
||||||
|
t.Fatalf("location lists %d and %d encoded identically", previous, index)
|
||||||
|
}
|
||||||
|
keys[key] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectInheritanceStepsByScan(origin Origin) []InheritanceStep {
|
||||||
|
steps := cloneInheritanceSteps(origin.Inheritance)
|
||||||
|
for _, contributor := range origin.contributors {
|
||||||
|
var reverse []*inheritanceTrace
|
||||||
|
for link := contributor.trace; link != nil; link = link.previous {
|
||||||
|
reverse = append(reverse, link)
|
||||||
|
}
|
||||||
|
current := contributor.source
|
||||||
|
for index := len(reverse) - 1; index >= 0; index-- {
|
||||||
|
transfer := reverse[index].transfer
|
||||||
|
relative, ok := relativeHistoryPath(transfer.from, current)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := appendInheritancePath(transfer.to, relative)
|
||||||
|
step := InheritanceStep{
|
||||||
|
From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
|
||||||
|
Locations: cloneLocations(transfer.locations),
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(steps, func(existing InheritanceStep) bool {
|
||||||
|
return existing.From.String() == step.From.String() &&
|
||||||
|
existing.To.String() == step.To.String() &&
|
||||||
|
existing.Reference.String() == step.Reference.String() &&
|
||||||
|
slices.Equal(existing.Locations, step.Locations)
|
||||||
|
}) {
|
||||||
|
steps = append(steps, step)
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
func TestInheritedOriginsRetainChainsAndNestedDestinations(t *testing.T) {
|
func TestInheritedOriginsRetainChainsAndNestedDestinations(t *testing.T) {
|
||||||
first := parseCompositionDocument(t, "source-1", `first:
|
first := parseCompositionDocument(t, "source-1", `first:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ func TestSiblingInheritanceRejectsInvalidRelationships(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDetectsInvalidInheritanceInsideSequence(t *testing.T) {
|
||||||
|
input := []byte(`items:
|
||||||
|
- child: {extends: true}
|
||||||
|
`)
|
||||||
|
result, err := testLoad[map[string]any](Bytes("nested-inheritance.yml", input), Options[map[string]any]{})
|
||||||
|
if result != nil || err == nil {
|
||||||
|
t.Fatalf("result, err = %#v, %v; want invalid nested inheritance", result, err)
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("error type = %T, want *LoadError", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceInvalid || diagnostics[0].Path.String() != "/items/0/child/extends" {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSiblingInheritanceDetectsDeepCycleWithoutRecursiveDependencyWalk(t *testing.T) {
|
func TestSiblingInheritanceDetectsDeepCycleWithoutRecursiveDependencyWalk(t *testing.T) {
|
||||||
const count = 2048
|
const count = 2048
|
||||||
entries := make([]document.Entry, count)
|
entries := make([]document.Entry, count)
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ type Inspection struct {
|
|||||||
input *document.Node
|
input *document.Node
|
||||||
inputOrigins map[string]Origin
|
inputOrigins map[string]Origin
|
||||||
final *document.Node
|
final *document.Node
|
||||||
|
immutableLookup *documentLookup
|
||||||
resolution []resolutionSnapshot
|
resolution []resolutionSnapshot
|
||||||
|
resolutionReplay []resolutionSnapshot
|
||||||
applications []historyApplication
|
applications []historyApplication
|
||||||
inheritance []inheritanceOperation
|
inheritance []inheritanceOperation
|
||||||
secrets map[string]bool
|
secrets map[string]bool
|
||||||
|
|||||||
@@ -157,15 +157,12 @@ func originRepresentedByChange(path Path, origin Origin, change Change) bool {
|
|||||||
// A subtree replacement already records its descendants' contributions.
|
// A subtree replacement already records its descendants' contributions.
|
||||||
// Their coordinates differ from the container's, but their sources must be
|
// Their coordinates differ from the container's, but their sources must be
|
||||||
// covered by the operation; matching only the precedence role is insufficient.
|
// covered by the operation; matching only the precedence role is insufficient.
|
||||||
|
recordedSources := make(map[string]struct{}, len(change.Origin.Locations))
|
||||||
|
for _, recorded := range change.Origin.Locations {
|
||||||
|
recordedSources[recorded.SourceID] = struct{}{}
|
||||||
|
}
|
||||||
for _, location := range origin.Locations {
|
for _, location := range origin.Locations {
|
||||||
found := false
|
if _, found := recordedSources[location.SourceID]; !found {
|
||||||
for _, recorded := range change.Origin.Locations {
|
|
||||||
if location.SourceID == recorded.SourceID {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,15 +186,12 @@ func originContainedBy(candidate, container Origin) bool {
|
|||||||
if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
|
if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
availableLocations := make(map[Location]struct{}, len(container.Locations))
|
||||||
|
for _, location := range container.Locations {
|
||||||
|
availableLocations[location] = struct{}{}
|
||||||
|
}
|
||||||
for _, location := range candidate.Locations {
|
for _, location := range candidate.Locations {
|
||||||
found := false
|
if _, found := availableLocations[location]; !found {
|
||||||
for _, available := range container.Locations {
|
|
||||||
if location == available {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,9 +248,10 @@ func cloneMetadata(metadata Metadata) Metadata {
|
|||||||
return Metadata{}
|
return Metadata{}
|
||||||
}
|
}
|
||||||
cloned := Metadata{
|
cloned := Metadata{
|
||||||
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||||
history: make(map[string][]Change, len(metadata.history)),
|
history: make(map[string][]Change, len(metadata.history)),
|
||||||
inheritance: append([]inheritanceOperation(nil), metadata.inheritance...),
|
inheritance: append([]inheritanceOperation(nil), metadata.inheritance...),
|
||||||
|
inheritanceIndex: metadata.inheritanceIndex,
|
||||||
}
|
}
|
||||||
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
|
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
|
||||||
for pointer, path := range metadata.effectivePaths {
|
for pointer, path := range metadata.effectivePaths {
|
||||||
@@ -280,8 +275,23 @@ func cloneMetadata(metadata Metadata) Metadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
|
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
|
||||||
for _, snapshot := range resolved.snapshots {
|
finalPaths := make(map[string]*document.Node)
|
||||||
pointer := snapshot.change.Path.String()
|
indexDocument(resolved.final, Path{}, finalPaths)
|
||||||
|
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
|
||||||
|
var deltaCoverage pathPrefixIndex
|
||||||
|
snapshotIDs := make([]int, len(resolved.deltas))
|
||||||
|
for id, delta := range resolved.deltas {
|
||||||
|
if delta.path.valid() {
|
||||||
|
deltaCoverage.insert(delta.path.String(), id)
|
||||||
|
}
|
||||||
|
snapshotIDs[id] = -1
|
||||||
|
if snapshotID, ok := snapshotCoverage.deepestCoveringID(delta.path); ok {
|
||||||
|
snapshotIDs[id] = snapshotID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for snapshotID, snapshot := range resolved.snapshots {
|
||||||
|
pointer := snapshotPointers[snapshotID]
|
||||||
change := cloneChange(snapshot.change)
|
change := cloneChange(snapshot.change)
|
||||||
if info, ok := metadata.fields[pointer]; ok {
|
if info, ok := metadata.fields[pointer]; ok {
|
||||||
change.Previous = []Origin{cloneOrigin(info.Origin)}
|
change.Previous = []Origin{cloneOrigin(info.Origin)}
|
||||||
@@ -290,52 +300,40 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
|
|||||||
}
|
}
|
||||||
metadata.history[pointer] = append(metadata.history[pointer], change)
|
metadata.history[pointer] = append(metadata.history[pointer], change)
|
||||||
}
|
}
|
||||||
appendResolutionDescendantHistory(metadata, resolved)
|
|
||||||
for _, delta := range resolved.deltas {
|
appendResolutionDescendantHistory(metadata, resolved.snapshots, snapshotPointers, finalPaths, &deltaCoverage, snapshotIDs)
|
||||||
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
|
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
|
||||||
if record == nil {
|
refreshResolutionFields(metadata.fields, finalPaths, resolved.snapshots, snapshotCoverage)
|
||||||
continue
|
|
||||||
}
|
|
||||||
for pointer, info := range metadata.fields {
|
|
||||||
path, err := ParsePath(pointer)
|
|
||||||
if err != nil || !pathContains(delta.path, path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info.Origin = cloneOrigin(record.change.Origin)
|
|
||||||
metadata.fields[pointer] = info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshResolutionFields(metadata.fields, resolved.final, resolved.snapshots)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionResult) {
|
func appendResolutionDescendantHistory(metadata *Metadata, snapshots []resolutionSnapshot, snapshotPointers []string, finalPaths map[string]*document.Node, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
|
||||||
finalPaths := make(map[string]*document.Node)
|
allPointers := make(map[string]struct{}, len(metadata.history)+len(finalPaths))
|
||||||
indexDocument(resolved.final, Path{}, finalPaths)
|
for pointer := range metadata.history {
|
||||||
for _, delta := range resolved.deltas {
|
if _, err := ParsePath(pointer); err == nil {
|
||||||
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
|
allPointers[pointer] = struct{}{}
|
||||||
if record == nil {
|
}
|
||||||
|
}
|
||||||
|
for pointer := range finalPaths {
|
||||||
|
allPointers[pointer] = struct{}{}
|
||||||
|
}
|
||||||
|
buckets := make([][]string, len(snapshotIDs))
|
||||||
|
for pointer := range allPointers {
|
||||||
|
deltaIndex.visitAncestors(pointer, func(deltaID int) {
|
||||||
|
snapshotID := snapshotIDs[deltaID]
|
||||||
|
if snapshotID < 0 || pointer == snapshotPointers[snapshotID] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buckets[deltaID] = append(buckets[deltaID], pointer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for deltaID, pointers := range buckets {
|
||||||
|
snapshotID := snapshotIDs[deltaID]
|
||||||
|
if snapshotID < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
affected := make(map[string]bool)
|
|
||||||
for pointer := range metadata.history {
|
|
||||||
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
|
|
||||||
affected[pointer] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pointer := range finalPaths {
|
|
||||||
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
|
|
||||||
affected[pointer] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pointers := make([]string, 0, len(affected))
|
|
||||||
for pointer := range affected {
|
|
||||||
if pointer != record.change.Path.String() {
|
|
||||||
pointers = append(pointers, pointer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(pointers)
|
slices.Sort(pointers)
|
||||||
for _, pointer := range pointers {
|
for _, pointer := range pointers {
|
||||||
change := cloneChange(record.change)
|
change := cloneChange(snapshots[snapshotID].change)
|
||||||
if info, ok := metadata.fields[pointer]; ok {
|
if info, ok := metadata.fields[pointer]; ok {
|
||||||
change.Previous = []Origin{cloneOrigin(info.Origin)}
|
change.Previous = []Origin{cloneOrigin(info.Origin)}
|
||||||
}
|
}
|
||||||
@@ -344,20 +342,33 @@ func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot {
|
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
|
||||||
var covering *resolutionSnapshot
|
buckets := make([][]string, len(snapshotIDs))
|
||||||
for index := range snapshots {
|
for pointer := range fields {
|
||||||
snapshot := &snapshots[index]
|
if _, err := ParsePath(pointer); err != nil {
|
||||||
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) {
|
continue
|
||||||
covering = snapshot
|
}
|
||||||
|
deltaIndex.visitAncestors(pointer, func(deltaID int) {
|
||||||
|
if snapshotIDs[deltaID] >= 0 {
|
||||||
|
buckets[deltaID] = append(buckets[deltaID], pointer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for deltaID, pointers := range buckets {
|
||||||
|
snapshotID := snapshotIDs[deltaID]
|
||||||
|
if snapshotID < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
origin := snapshots[snapshotID].change.Origin
|
||||||
|
for _, pointer := range pointers {
|
||||||
|
info := fields[pointer]
|
||||||
|
info.Origin = cloneOrigin(origin)
|
||||||
|
fields[pointer] = info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return covering
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node, snapshots []resolutionSnapshot) {
|
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) {
|
||||||
finalPaths := make(map[string]*document.Node)
|
|
||||||
indexDocument(final, Path{}, finalPaths)
|
|
||||||
for pointer := range fields {
|
for pointer := range fields {
|
||||||
if _, ok := finalPaths[pointer]; !ok {
|
if _, ok := finalPaths[pointer]; !ok {
|
||||||
delete(fields, pointer)
|
delete(fields, pointer)
|
||||||
@@ -370,10 +381,8 @@ func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
origin := Origin{Layer: LayerResolution}
|
origin := Origin{Layer: LayerResolution}
|
||||||
if path, err := ParsePath(pointer); err == nil {
|
if snapshotID, ok := snapshotCoverage.deepestCoveringPointerID(pointer); ok {
|
||||||
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil {
|
origin = cloneOrigin(snapshots[snapshotID].change.Origin)
|
||||||
origin = cloneOrigin(covering.change.Origin)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
|
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
|||||||
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||||
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
||||||
|
retainedTrees := make([]*document.Node, 0, len(applications)+2)
|
||||||
|
retainedTrees = append(retainedTrees, decoded.final, final)
|
||||||
|
for _, application := range applications {
|
||||||
|
retainedTrees = append(retainedTrees, application.value)
|
||||||
|
}
|
||||||
inspection := &Inspection{
|
inspection := &Inspection{
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
sources: cloneSourceInfos(accumulator.sources),
|
sources: cloneSourceInfos(accumulator.sources),
|
||||||
@@ -107,7 +112,9 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
|
|||||||
input: decoded.final,
|
input: decoded.final,
|
||||||
inputOrigins: cloneOriginIndex(decoded.origins),
|
inputOrigins: cloneOriginIndex(decoded.origins),
|
||||||
final: final,
|
final: final,
|
||||||
|
immutableLookup: buildDocumentLookup(retainedTrees...),
|
||||||
resolution: resolutionSnapshots,
|
resolution: resolutionSnapshots,
|
||||||
|
resolutionReplay: coalescedResolutionSnapshots(resolutionSnapshots),
|
||||||
applications: applications,
|
applications: applications,
|
||||||
inheritance: inheritanceOperations(resolved.inheritance),
|
inheritance: inheritanceOperations(resolved.inheritance),
|
||||||
secrets: secrets,
|
secrets: secrets,
|
||||||
|
|||||||
@@ -9,20 +9,18 @@ import (
|
|||||||
// 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.
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
effectivePaths map[string]Path
|
effectivePaths map[string]Path
|
||||||
historicalPaths map[string][]Path
|
historicalPaths map[string][]Path
|
||||||
fields map[string]FieldInfo
|
fields map[string]FieldInfo
|
||||||
history map[string][]Change
|
history map[string][]Change
|
||||||
inheritance []inheritanceOperation
|
inheritance []inheritanceOperation
|
||||||
|
inheritanceIndex *inheritanceHistoryIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup returns information for an effective path. Unknown and invalid paths
|
// Lookup returns information for an effective path. Unknown and invalid paths
|
||||||
// return false.
|
// return false.
|
||||||
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
||||||
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
|
info, ok := m.effectiveField(path)
|
||||||
return FieldInfo{}, false
|
|
||||||
}
|
|
||||||
info, ok := m.fields[path.String()]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return FieldInfo{}, false
|
return FieldInfo{}, false
|
||||||
}
|
}
|
||||||
@@ -34,10 +32,23 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
|||||||
// IsExplicit reports whether a surviving input contribution, including one
|
// IsExplicit reports whether a surviving input contribution, including one
|
||||||
// inherited from a sibling, explicitly defined path.
|
// inherited from a sibling, explicitly defined path.
|
||||||
func (m Metadata) IsExplicit(path Path) bool {
|
func (m Metadata) IsExplicit(path Path) bool {
|
||||||
info, ok := m.Lookup(path)
|
info, ok := m.effectiveField(path)
|
||||||
return ok && info.Explicit
|
return ok && info.Explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Metadata) effectiveField(path Path) (FieldInfo, bool) {
|
||||||
|
if !path.valid() {
|
||||||
|
return FieldInfo{}, false
|
||||||
|
}
|
||||||
|
pointer := path.String()
|
||||||
|
candidate, ok := m.effectivePaths[pointer]
|
||||||
|
if !ok || !matchesPathShape(path, candidate) {
|
||||||
|
return FieldInfo{}, false
|
||||||
|
}
|
||||||
|
info, ok := m.fields[pointer]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
|
|
||||||
// History returns detached definition, inheritance, and resolution records for path. A record's
|
// History returns detached definition, inheritance, and resolution records for path. A record's
|
||||||
// Path can be an ancestor when that container operation replaced or removed path.
|
// Path can be an ancestor when that container operation replaced or removed path.
|
||||||
// Removed historical paths can have history even though Lookup returns false.
|
// Removed historical paths can have history even though Lookup returns false.
|
||||||
@@ -47,7 +58,11 @@ func (m Metadata) History(path Path) []Change {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
history := m.history[path.String()]
|
history := m.history[path.String()]
|
||||||
return historyWithInheritance(path, history, m.inheritance)
|
operations := m.inheritance
|
||||||
|
if m.inheritanceIndex != nil {
|
||||||
|
operations = m.inheritanceIndex.matchingOperations(path, m.inheritance)
|
||||||
|
}
|
||||||
|
return historyWithInheritance(path, history, operations)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoricalPaths returns the paths observed during loading, including effective
|
// HistoricalPaths returns the paths observed during loading, including effective
|
||||||
@@ -79,11 +94,14 @@ func (m Metadata) HistoricalPaths() []Path {
|
|||||||
// 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 {
|
||||||
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
|
if !query.valid() || len(query.segments) != len(candidate.segments) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for i, segment := range query.segments {
|
for i, segment := range query.segments {
|
||||||
actual := candidate.segments[i]
|
actual := candidate.segments[i]
|
||||||
|
if !pathTokenSpellingEqual(segment, actual) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if actual.kind == pathSegmentIndex {
|
if actual.kind == pathSegmentIndex {
|
||||||
index, ok := schemaIndex(segment)
|
index, ok := schemaIndex(segment)
|
||||||
if !ok || index != actual.index {
|
if !ok || index != actual.index {
|
||||||
@@ -109,10 +127,9 @@ func (m Metadata) hasHistoricalPath(path Path) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
||||||
m.effectivePaths = effectivePathIndex(final)
|
m.effectivePaths = make(map[string]Path)
|
||||||
m.historicalPaths = make(map[string][]Path)
|
m.historicalPaths = make(map[string][]Path)
|
||||||
add := func(path Path, _ *document.Node) {
|
addHistorical := func(path Path, pointer string) {
|
||||||
pointer := path.String()
|
|
||||||
for _, prior := range m.historicalPaths[pointer] {
|
for _, prior := range m.historicalPaths[pointer] {
|
||||||
if matchesPathShape(path, prior) {
|
if matchesPathShape(path, prior) {
|
||||||
return
|
return
|
||||||
@@ -120,7 +137,14 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
|
|||||||
}
|
}
|
||||||
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
||||||
}
|
}
|
||||||
walkDocument(final, Path{}, add)
|
walkDocument(final, Path{}, func(path Path, _ *document.Node) {
|
||||||
|
pointer := path.String()
|
||||||
|
m.effectivePaths[pointer] = path.clone()
|
||||||
|
addHistorical(path, pointer)
|
||||||
|
})
|
||||||
|
add := func(path Path, _ *document.Node) {
|
||||||
|
addHistorical(path, path.String())
|
||||||
|
}
|
||||||
for _, tree := range earlier {
|
for _, tree := range earlier {
|
||||||
walkDocument(tree, Path{}, add)
|
walkDocument(tree, Path{}, add)
|
||||||
}
|
}
|
||||||
@@ -142,6 +166,7 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
|
|||||||
walkDocument(operation.effective, operation.transfer.to, add)
|
walkDocument(operation.effective, operation.transfer.to, add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
m.inheritanceIndex = newInheritanceHistoryIndex(m.inheritance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectivePathIndex records document-derived paths with their contextual segment
|
// effectivePathIndex records document-derived paths with their contextual segment
|
||||||
|
|||||||
@@ -47,6 +47,60 @@ func TestMetadataHistoricalPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetadataPathIndexesOwnIndependentSegments(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
final := parseDocumentForDecode(t, "service: {workers: [1]}\n")
|
||||||
|
var metadata Metadata
|
||||||
|
finalizeMetadataPaths(&metadata, final, nil)
|
||||||
|
pointer := "/service/workers/0"
|
||||||
|
effective := metadata.effectivePaths[pointer]
|
||||||
|
historical := metadata.historicalPaths[pointer]
|
||||||
|
if effective.String() != pointer || len(historical) != 1 || historical[0].String() != pointer {
|
||||||
|
t.Fatalf("path indexes = %#v, %#v", effective, historical)
|
||||||
|
}
|
||||||
|
|
||||||
|
effective.segments[0].key = "effective"
|
||||||
|
if historical[0].String() != pointer {
|
||||||
|
t.Fatal("effective path mutation changed historical path")
|
||||||
|
}
|
||||||
|
historical[0].segments[2].index = 1
|
||||||
|
if effective.String() != "/effective/workers/0" {
|
||||||
|
t.Fatal("historical path mutation changed effective path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataPathIndexesRetainDistinctHistoricalShapes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
final := parseDocumentForDecode(t, "shape: [replacement]\n")
|
||||||
|
earlier := parseDocumentForDecode(t, "shape: {'0': base}\n")
|
||||||
|
var metadata Metadata
|
||||||
|
// Repeated trees must not add duplicate shapes to the historical index.
|
||||||
|
finalizeMetadataPaths(&metadata, final, nil, earlier, earlier, final)
|
||||||
|
keyPath := Keys("shape", "0")
|
||||||
|
indexPath := Keys("shape").Index(0)
|
||||||
|
pointer := indexPath.String()
|
||||||
|
if !reflect.DeepEqual(metadata.effectivePaths[pointer], indexPath) {
|
||||||
|
t.Fatal("effective index does not reflect the final sequence")
|
||||||
|
}
|
||||||
|
historical := metadata.historicalPaths[pointer]
|
||||||
|
if len(historical) != 2 {
|
||||||
|
t.Fatalf("historical shapes = %#v, want one key and one index", historical)
|
||||||
|
}
|
||||||
|
for _, want := range []Path{keyPath, indexPath} {
|
||||||
|
found := false
|
||||||
|
for _, path := range historical {
|
||||||
|
if reflect.DeepEqual(path, want) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found || !metadata.hasHistoricalPath(want) {
|
||||||
|
t.Fatalf("historical index lost shape %#v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
|
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
|
||||||
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
|
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
|
||||||
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
|
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
|
||||||
@@ -231,6 +285,75 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetadataIsExplicitAgreesWithLookup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
Kept string `yaml:"kept"`
|
||||||
|
Set string `yaml:"set"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Nested entry `yaml:"nested"`
|
||||||
|
Nullable *string `yaml:"nullable"`
|
||||||
|
Nodes map[string]entry `yaml:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
input := []byte(`nested: {set: configured, enabled: false, count: 0}
|
||||||
|
nullable: null
|
||||||
|
nodes:
|
||||||
|
base: {enabled: false, count: 0}
|
||||||
|
copy: {extends: base}
|
||||||
|
`)
|
||||||
|
result, err := testLoad(Bytes("metadata-presence.yml", input), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config {
|
||||||
|
return config{
|
||||||
|
Nested: entry{Kept: "default", Set: "default", Enabled: true, Count: 7},
|
||||||
|
Nodes: map[string]entry{"copy": {Kept: "node default"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path Path
|
||||||
|
wantExplicit bool
|
||||||
|
wantNull bool
|
||||||
|
wantInherited bool
|
||||||
|
}{
|
||||||
|
{name: "default", path: Keys("nested", "kept")},
|
||||||
|
{name: "direct input", path: Keys("nested", "set"), wantExplicit: true},
|
||||||
|
{name: "explicit false", path: Keys("nested", "enabled"), wantExplicit: true},
|
||||||
|
{name: "explicit zero", path: Keys("nested", "count"), wantExplicit: true},
|
||||||
|
{name: "explicit null", path: Keys("nullable"), wantExplicit: true, wantNull: true},
|
||||||
|
{name: "inherited input", path: Keys("nodes", "copy", "enabled"), wantExplicit: true, wantInherited: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
info, found := metadata.Lookup(test.path)
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Lookup(%s) did not find an effective path", test.path)
|
||||||
|
}
|
||||||
|
if got := metadata.IsExplicit(test.path); got != info.Explicit || got != test.wantExplicit {
|
||||||
|
t.Fatalf("IsExplicit(%s) = %v, Lookup explicit = %v, want %v", test.path, got, info.Explicit, test.wantExplicit)
|
||||||
|
}
|
||||||
|
if info.Null != test.wantNull {
|
||||||
|
t.Fatalf("Lookup(%s).Null = %v, want %v", test.path, info.Null, test.wantNull)
|
||||||
|
}
|
||||||
|
if inherited := len(info.Origin.Inheritance) != 0; inherited != test.wantInherited {
|
||||||
|
t.Fatalf("Lookup(%s) inherited = %v, want %v", test.path, inherited, test.wantInherited)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if result.Config.Nested.Enabled || result.Config.Nested.Count != 0 || result.Config.Nullable != nil {
|
||||||
|
t.Fatalf("explicit values changed during loading: %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
|
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
|
||||||
result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
|
result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -131,9 +131,24 @@ func previousOrigins(origin Origin) []Origin {
|
|||||||
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
|
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
|
||||||
var displaced []displacedDefinition
|
var displaced []displacedDefinition
|
||||||
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
|
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
|
||||||
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
|
return sortDisplacedDefinitions(displaced)
|
||||||
return strings.Compare(left.path.String(), right.path.String())
|
}
|
||||||
|
|
||||||
|
func sortDisplacedDefinitions(displaced []displacedDefinition) []displacedDefinition {
|
||||||
|
type orderedDefinition struct {
|
||||||
|
definition displacedDefinition
|
||||||
|
pointer string
|
||||||
|
}
|
||||||
|
ordered := make([]orderedDefinition, len(displaced))
|
||||||
|
for i, definition := range displaced {
|
||||||
|
ordered[i] = orderedDefinition{definition: definition, pointer: definition.path.String()}
|
||||||
|
}
|
||||||
|
slices.SortFunc(ordered, func(left, right orderedDefinition) int {
|
||||||
|
return strings.Compare(left.pointer, right.pointer)
|
||||||
})
|
})
|
||||||
|
for i, item := range ordered {
|
||||||
|
displaced[i] = item.definition
|
||||||
|
}
|
||||||
return displaced
|
return displaced
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,3 +117,26 @@ func TestOwnedOverlayKeepsCompositeContributorsAndAcquiredInputs(t *testing.T) {
|
|||||||
t.Fatal("working tree mutation changed an acquired input")
|
t.Fatal("working tree mutation changed an acquired input")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisplacedOriginsSortEscapedPointers(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", `values:
|
||||||
|
z: old
|
||||||
|
a/b: slash
|
||||||
|
a~b: tilde
|
||||||
|
nested: {x: old}
|
||||||
|
`)
|
||||||
|
composed, diagnostics := composeDocuments(input)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
displaced := displacedOrigins(composed.root, composed.origins, Path{})
|
||||||
|
want := []string{"", "/values", "/values/a~0b", "/values/a~1b", "/values/nested", "/values/nested/x", "/values/z"}
|
||||||
|
if len(displaced) != len(want) {
|
||||||
|
t.Fatalf("displaced definitions = %d, want %d", len(displaced), len(want))
|
||||||
|
}
|
||||||
|
for i, definition := range displaced {
|
||||||
|
if got := definition.path.String(); got != want[i] {
|
||||||
|
t.Fatalf("displaced[%d] path = %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,12 +50,16 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
|
|||||||
}
|
}
|
||||||
|
|
||||||
seen := make(map[string]int, len(overrides))
|
seen := make(map[string]int, len(overrides))
|
||||||
pathAccepted := make([]bool, len(overrides))
|
var acceptedPaths pathPrefixIndex
|
||||||
|
var baseLookup *documentLookup
|
||||||
|
if len(overrides) != 0 {
|
||||||
|
baseLookup = buildDocumentLookup(base.root)
|
||||||
|
}
|
||||||
var usedBytes int64
|
var usedBytes int64
|
||||||
usedNodes := 0
|
usedNodes := 0
|
||||||
for index, override := range overrides {
|
for index, override := range overrides {
|
||||||
location := &Location{SourceID: prepared[index].source.ID}
|
location := &Location{SourceID: prepared[index].source.ID}
|
||||||
if issue := validateOverridePath(override.Path, base.root); issue != nil {
|
if issue := validateOverridePath(override.Path, base.root, baseLookup); issue != nil {
|
||||||
issue.Primary = location
|
issue.Primary = location
|
||||||
diagnostics = append(diagnostics, *issue)
|
diagnostics = append(diagnostics, *issue)
|
||||||
continue
|
continue
|
||||||
@@ -70,7 +74,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[pointer] = index
|
seen[pointer] = index
|
||||||
if conflicting := overlappingOverride(overrides[:index], pathAccepted[:index], override.Path); conflicting >= 0 {
|
if conflicting, overlaps := acceptedPaths.earliestIntersectingID(pointer); overlaps {
|
||||||
diagnostics = append(diagnostics, Diagnostic{
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
|
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
|
||||||
Message: "configuration override paths overlap", Primary: location,
|
Message: "configuration override paths overlap", Primary: location,
|
||||||
@@ -92,7 +96,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pathAccepted[index] = true
|
acceptedPaths.insert(pointer, index)
|
||||||
|
|
||||||
builder := valueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
|
builder := valueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
|
||||||
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
|
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
|
||||||
@@ -150,7 +154,7 @@ func validOverrideLabel(label string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOverridePath(path Path, root *document.Node) *Diagnostic {
|
func validateOverridePath(path Path, root *document.Node, lookup *documentLookup) *Diagnostic {
|
||||||
if !path.valid() || len(path.segments) == 0 {
|
if !path.valid() || len(path.segments) == 0 {
|
||||||
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"}
|
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"}
|
||||||
}
|
}
|
||||||
@@ -165,7 +169,7 @@ func validateOverridePath(path Path, root *document.Node) *Diagnostic {
|
|||||||
if current.Kind != document.Mapping {
|
if current.Kind != document.Mapping {
|
||||||
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"}
|
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"}
|
||||||
}
|
}
|
||||||
position := documentEntryIndex(current.Mapping, segment.key)
|
position := lookup.find(current, segment.key)
|
||||||
if index == len(path.segments)-1 {
|
if index == len(path.segments)-1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -178,15 +182,6 @@ func validateOverridePath(path Path, root *document.Node) *Diagnostic {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func overlappingOverride(previous []Override, valid []bool, path Path) int {
|
|
||||||
for index, override := range previous {
|
|
||||||
if valid[index] && (pathContains(override.Path, path) || pathContains(path, override.Path)) {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathContainsDirective(path Path) bool {
|
func pathContainsDirective(path Path) bool {
|
||||||
for _, segment := range path.segments {
|
for _, segment := range path.segments {
|
||||||
if segment.key == directiveKey {
|
if segment.key == directiveKey {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,26 +83,28 @@ func applyPreparedOverride(result *composedDocument, override preparedOverride,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) []displacedDefinition {
|
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) []displacedDefinition {
|
||||||
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
|
var displaced []displacedDefinition
|
||||||
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
|
collectAffectedOverrideOrigins(&displaced, existing, incoming, origins, path, index)
|
||||||
for _, entry := range incoming.Mapping {
|
return sortDisplacedDefinitions(displaced)
|
||||||
position := index.find(existing, entry.Key)
|
}
|
||||||
if position < 0 {
|
|
||||||
continue
|
func collectAffectedOverrideOrigins(displaced *[]displacedDefinition, existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) {
|
||||||
}
|
if existing.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||||
childPath := path.Key(entry.Key)
|
collectDisplacedOrigins(displaced, existing, origins, path, Origin{})
|
||||||
childExisting := existing.Mapping[position].Value
|
return
|
||||||
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
|
}
|
||||||
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath, index)...)
|
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origins[path.String()])})
|
||||||
} else {
|
for _, entry := range incoming.Mapping {
|
||||||
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
|
position := index.find(existing, entry.Key)
|
||||||
}
|
if position < 0 {
|
||||||
}
|
continue
|
||||||
} else {
|
}
|
||||||
displaced = displacedOrigins(existing, origins, path)
|
childPath := path.Key(entry.Key)
|
||||||
}
|
childExisting := existing.Mapping[position].Value
|
||||||
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
|
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
|
||||||
return strings.Compare(left.path.String(), right.path.String())
|
collectAffectedOverrideOrigins(displaced, childExisting, entry.Value, origins, childPath, index)
|
||||||
})
|
} else {
|
||||||
return displaced
|
collectDisplacedOrigins(displaced, childExisting, origins, childPath, Origin{})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,132 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOverrideOverlapIndexPreservesFirstAcceptedSource(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Nested map[string]any `yaml:"nested"`
|
||||||
|
}
|
||||||
|
base := Bytes("override-overlap.yml", []byte("nested: {}\n"))
|
||||||
|
_, err := testLoad(base, Options[config]{Overrides: []Override{
|
||||||
|
{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"},
|
||||||
|
{Path: Keys("nested", "first"), Value: "one", Flag: "--first"},
|
||||||
|
{Path: Keys("nested", "second"), Value: "two", Flag: "--second"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("load succeeded with overlapping overrides")
|
||||||
|
}
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("error = %T %v, want *LoadError", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
var conflicts []Diagnostic
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if diagnostic.Code == CodeOverrideOverlap {
|
||||||
|
conflicts = append(conflicts, diagnostic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(conflicts) != 2 {
|
||||||
|
t.Fatalf("overlap diagnostics = %#v, want two conflicts", diagnostics)
|
||||||
|
}
|
||||||
|
for index, path := range []string{"/nested/first", "/nested/second"} {
|
||||||
|
if conflicts[index].Path.String() != path || len(conflicts[index].Related) != 1 || conflicts[index].Related[0].SourceID != "source-2" {
|
||||||
|
t.Fatalf("conflict[%d] = %#v, want path %q related to first CLI source", index, conflicts[index], path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverrideOverlapIndexIncludesAcceptedPathWithInvalidValue(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Nested map[string]any `yaml:"nested"`
|
||||||
|
}
|
||||||
|
base := Bytes("override-value-overlap.yml", []byte("nested: {}\n"))
|
||||||
|
_, err := testLoad(base, Options[config]{Overrides: []Override{
|
||||||
|
{Path: Keys("nested"), Value: time.Second, Flag: "--nested"},
|
||||||
|
{Path: Keys("nested", "child"), Value: "value", Flag: "--child"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("load succeeded with an invalid value and overlapping path")
|
||||||
|
}
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("error = %T %v, want *LoadError", err, err)
|
||||||
|
}
|
||||||
|
var invalidValue, overlap bool
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
switch diagnostic.Code {
|
||||||
|
case CodeOverrideInvalidValue:
|
||||||
|
invalidValue = diagnostic.Path.String() == "/nested"
|
||||||
|
case CodeOverrideOverlap:
|
||||||
|
overlap = diagnostic.Path.String() == "/nested/child" && len(diagnostic.Related) == 1 && diagnostic.Related[0].SourceID == "source-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !invalidValue || !overlap {
|
||||||
|
t.Fatalf("diagnostics = %#v, want invalid parent value and child overlap related to its source", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedOverridePathDoesNotCauseOverlap(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Scalar string `yaml:"scalar"`
|
||||||
|
}
|
||||||
|
base := Bytes("override-rejected-overlap.yml", []byte("scalar: value\n"))
|
||||||
|
_, err := testLoad(base, Options[config]{Overrides: []Override{
|
||||||
|
{Path: Keys("scalar", "child"), Value: "invalid", Flag: "--child"},
|
||||||
|
{Path: Keys("scalar"), Value: "valid", Flag: "--scalar"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("load succeeded with a path traversing a scalar")
|
||||||
|
}
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("error = %T %v, want *LoadError", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideInvalidPath || diagnostics[0].Path.String() != "/scalar/child" {
|
||||||
|
t.Fatalf("diagnostics = %#v, want only the invalid descendant path", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionRejectedOverridePathDoesNotCauseOverlap(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Nested map[string]any `yaml:"nested"`
|
||||||
|
}
|
||||||
|
base := Bytes("override-version-overlap.yml", []byte("nested: {}\n"))
|
||||||
|
version := &VersionRule{Path: Keys("nested", "locked"), Check: func(Version, *Validation) error { return nil }}
|
||||||
|
_, err := testLoad(base, Options[config]{
|
||||||
|
Definition: Definition[config]{Version: version},
|
||||||
|
Overrides: []Override{
|
||||||
|
{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"},
|
||||||
|
{Path: Keys("nested", "other"), Value: "allowed", Flag: "--other"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("load succeeded despite an override of a schema-version ancestor")
|
||||||
|
}
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("error = %T %v, want *LoadError", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideVersion || diagnostics[0].Path.String() != "/nested" {
|
||||||
|
t.Fatalf("diagnostics = %#v, want only the protected ancestor path", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapedOverrideKeysStayDistinctFromMultipleSegments(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Nested map[string]any `yaml:"nested"`
|
||||||
|
}
|
||||||
|
base := Bytes("override-escaped-keys.yml", []byte("nested: {}\n"))
|
||||||
|
_, err := testLoad(base, Options[config]{Overrides: []Override{
|
||||||
|
{Path: Keys("nested", "a/b"), Value: "single key", Flag: "--single"},
|
||||||
|
{Path: Keys("nested", "a", "b"), Value: "nested key", Flag: "--nested"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load rejected distinct escaped path tokens: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
||||||
type schemaConfig struct {
|
type schemaConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
@@ -271,6 +397,43 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAffectedOverrideOriginsSortEscapedPointers(t *testing.T) {
|
||||||
|
existingInput := parseCompositionDocument(t, "source-1", `values:
|
||||||
|
z: old
|
||||||
|
a/b: slash
|
||||||
|
a~b: tilde
|
||||||
|
nested: {x: old}
|
||||||
|
`)
|
||||||
|
existing, diagnostics := composeDocuments(existingInput)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
incoming := parseCompositionDocument(t, "source-2", `values:
|
||||||
|
z: new
|
||||||
|
a/b: new
|
||||||
|
a~b: new
|
||||||
|
nested: {x: new}
|
||||||
|
`)
|
||||||
|
existingValues, ok := documentAtPath(existing.root, Keys("values"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("existing values mapping is missing")
|
||||||
|
}
|
||||||
|
incomingValues, ok := documentAtPath(incoming, Keys("values"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("incoming values mapping is missing")
|
||||||
|
}
|
||||||
|
displaced := affectedOverrideOrigins(existingValues, incomingValues, existing.origins, Keys("values"), &mappingIndex{})
|
||||||
|
want := []string{"/values", "/values/a~0b", "/values/a~1b", "/values/nested", "/values/nested/x", "/values/z"}
|
||||||
|
if len(displaced) != len(want) {
|
||||||
|
t.Fatalf("displaced definitions = %d, want %d", len(displaced), len(want))
|
||||||
|
}
|
||||||
|
for i, definition := range displaced {
|
||||||
|
if got := definition.path.String(); got != want[i] {
|
||||||
|
t.Fatalf("displaced[%d] path = %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
|
func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
|
||||||
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
|
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
|
|||||||
@@ -86,7 +86,20 @@ func (p Path) String() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include each separator and the extra byte introduced by escaping a token.
|
||||||
|
// Use stack storage for sizing indexes without allocating another string.
|
||||||
|
size := len(p.segments)
|
||||||
|
var indexText [20]byte
|
||||||
|
for _, segment := range p.segments {
|
||||||
|
if segment.kind == pathSegmentIndex {
|
||||||
|
size += len(strconv.AppendInt(indexText[:0], int64(segment.index), 10))
|
||||||
|
} else {
|
||||||
|
size += len(segment.key) + strings.Count(segment.key, "~") + strings.Count(segment.key, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
b.Grow(size)
|
||||||
for _, segment := range p.segments {
|
for _, segment := range p.segments {
|
||||||
b.WriteByte('/')
|
b.WriteByte('/')
|
||||||
if segment.kind == pathSegmentIndex {
|
if segment.kind == pathSegmentIndex {
|
||||||
@@ -115,6 +128,33 @@ func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathTokenSpellingEqual(left, right pathSegment) bool {
|
||||||
|
switch {
|
||||||
|
case left.kind == pathSegmentIndex && right.kind == pathSegmentIndex:
|
||||||
|
return left.index == right.index
|
||||||
|
case left.kind == pathSegmentIndex:
|
||||||
|
return pathIndexSpellingEqual(left.index, right.key)
|
||||||
|
case right.kind == pathSegmentIndex:
|
||||||
|
return pathIndexSpellingEqual(right.index, left.key)
|
||||||
|
default:
|
||||||
|
return left.key == right.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathIndexSpellingEqual(index int, token string) bool {
|
||||||
|
var scratch [20]byte
|
||||||
|
spelling := strconv.AppendInt(scratch[:0], int64(index), 10)
|
||||||
|
if len(spelling) != len(token) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, character := range spelling {
|
||||||
|
if character != token[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func escapePathToken(token string) string {
|
func escapePathToken(token string) string {
|
||||||
token = strings.ReplaceAll(token, "~", "~0")
|
token = strings.ReplaceAll(token, "~", "~0")
|
||||||
return strings.ReplaceAll(token, "/", "~1")
|
return strings.ReplaceAll(token, "/", "~1")
|
||||||
|
|||||||
138
internal/engine/path_prefix.go
Normal file
138
internal/engine/path_prefix.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
type pathPrefixIndex struct {
|
||||||
|
root pathPrefixNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type pathPrefixNode struct {
|
||||||
|
children map[string]*pathPrefixNode
|
||||||
|
ids []int
|
||||||
|
minimumID int
|
||||||
|
hasMinimum bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (node *pathPrefixNode) includeID(id int) {
|
||||||
|
if !node.hasMinimum || id < node.minimumID {
|
||||||
|
node.minimumID = id
|
||||||
|
node.hasMinimum = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *pathPrefixIndex) insert(pointer string, id int) {
|
||||||
|
node := &index.root
|
||||||
|
node.includeID(id)
|
||||||
|
visitCanonicalPointerTokens(pointer, func(token string) bool {
|
||||||
|
if node.children == nil {
|
||||||
|
node.children = make(map[string]*pathPrefixNode)
|
||||||
|
}
|
||||||
|
child := node.children[token]
|
||||||
|
if child == nil {
|
||||||
|
child = &pathPrefixNode{}
|
||||||
|
node.children[token] = child
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
node.includeID(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
node.ids = append(node.ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *pathPrefixIndex) visitAncestors(pointer string, visit func(int)) {
|
||||||
|
node := &index.root
|
||||||
|
for _, id := range node.ids {
|
||||||
|
visit(id)
|
||||||
|
}
|
||||||
|
visitCanonicalPointerTokens(pointer, func(token string) bool {
|
||||||
|
child := node.children[token]
|
||||||
|
if child == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
for _, id := range node.ids {
|
||||||
|
visit(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *pathPrefixIndex) visitDescendants(pointer string, visit func(int)) {
|
||||||
|
root := index.nodeAt(pointer)
|
||||||
|
if root == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var walk func(*pathPrefixNode)
|
||||||
|
walk = func(node *pathPrefixNode) {
|
||||||
|
for _, id := range node.ids {
|
||||||
|
visit(id)
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *pathPrefixIndex) earliestIntersectingID(pointer string) (int, bool) {
|
||||||
|
bestID, found := 0, false
|
||||||
|
consider := func(id int) {
|
||||||
|
if !found || id < bestID {
|
||||||
|
bestID = id
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node := &index.root
|
||||||
|
for _, id := range node.ids {
|
||||||
|
consider(id)
|
||||||
|
}
|
||||||
|
complete := visitCanonicalPointerTokens(pointer, func(token string) bool {
|
||||||
|
child := node.children[token]
|
||||||
|
if child == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
for _, id := range node.ids {
|
||||||
|
consider(id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if complete && node.hasMinimum {
|
||||||
|
consider(node.minimumID)
|
||||||
|
}
|
||||||
|
return bestID, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *pathPrefixIndex) nodeAt(pointer string) *pathPrefixNode {
|
||||||
|
node := &index.root
|
||||||
|
complete := visitCanonicalPointerTokens(pointer, func(token string) bool {
|
||||||
|
child := node.children[token]
|
||||||
|
if child == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if !complete {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func visitCanonicalPointerTokens(pointer string, visit func(string) bool) bool {
|
||||||
|
if pointer == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
start := 1
|
||||||
|
for {
|
||||||
|
end := start
|
||||||
|
for end < len(pointer) && pointer[end] != '/' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if !visit(pointer[start:end]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if end == len(pointer) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
start = end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
81
internal/engine/path_prefix_test.go
Normal file
81
internal/engine/path_prefix_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathPrefixIndexMatchesPointerScans(t *testing.T) {
|
||||||
|
entries := []struct {
|
||||||
|
pointer string
|
||||||
|
id int
|
||||||
|
}{
|
||||||
|
{pointer: "", id: 12},
|
||||||
|
{pointer: "/", id: 9},
|
||||||
|
{pointer: "/a", id: 6},
|
||||||
|
{pointer: "/a", id: 2},
|
||||||
|
{pointer: "/a/b", id: 4},
|
||||||
|
{pointer: "/a/b/c", id: 0},
|
||||||
|
{pointer: "/a/bc", id: 10},
|
||||||
|
{pointer: "/a~1b", id: 3},
|
||||||
|
{pointer: "/a~0b", id: 1},
|
||||||
|
{pointer: "/a~01b", id: 5},
|
||||||
|
{pointer: "/items/0", id: 8},
|
||||||
|
{pointer: "/items/01", id: 7},
|
||||||
|
{pointer: "/items/+1", id: 11},
|
||||||
|
}
|
||||||
|
var index pathPrefixIndex
|
||||||
|
for _, entry := range entries {
|
||||||
|
index.insert(entry.pointer, entry.id)
|
||||||
|
}
|
||||||
|
queries := []string{
|
||||||
|
"", "/", "/a", "/a/b", "/a/b/c", "/a/b/c/d", "/a/bc", "/a/bc/d",
|
||||||
|
"/a~1b", "/a~0b", "/a~01b", "/items", "/items/0", "/items/01", "/items/1", "/missing",
|
||||||
|
}
|
||||||
|
for _, query := range queries {
|
||||||
|
gotAncestors := make([]int, 0)
|
||||||
|
index.visitAncestors(query, func(id int) { gotAncestors = append(gotAncestors, id) })
|
||||||
|
wantAncestors := make([]int, 0)
|
||||||
|
gotDescendants := make([]int, 0)
|
||||||
|
wantDescendants := make([]int, 0)
|
||||||
|
wantEarliest, foundEarliest := 0, false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if pointerContainsByString(entry.pointer, query) {
|
||||||
|
wantAncestors = append(wantAncestors, entry.id)
|
||||||
|
}
|
||||||
|
if pointerContainsByString(query, entry.pointer) {
|
||||||
|
wantDescendants = append(wantDescendants, entry.id)
|
||||||
|
}
|
||||||
|
if pointerContainsByString(entry.pointer, query) || pointerContainsByString(query, entry.pointer) {
|
||||||
|
if !foundEarliest || entry.id < wantEarliest {
|
||||||
|
wantEarliest, foundEarliest = entry.id, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index.visitDescendants(query, func(id int) { gotDescendants = append(gotDescendants, id) })
|
||||||
|
if !slices.Equal(sortedPrefixIDs(gotAncestors), sortedPrefixIDs(wantAncestors)) {
|
||||||
|
t.Errorf("ancestors(%q) = %v, want %v", query, sortedPrefixIDs(gotAncestors), sortedPrefixIDs(wantAncestors))
|
||||||
|
}
|
||||||
|
if !slices.Equal(sortedPrefixIDs(gotDescendants), sortedPrefixIDs(wantDescendants)) {
|
||||||
|
t.Errorf("descendants(%q) = %v, want %v", query, sortedPrefixIDs(gotDescendants), sortedPrefixIDs(wantDescendants))
|
||||||
|
}
|
||||||
|
gotEarliest, gotFound := index.earliestIntersectingID(query)
|
||||||
|
if gotEarliest != wantEarliest || gotFound != foundEarliest {
|
||||||
|
t.Errorf("earliestIntersectingID(%q) = %d, %v, want %d, %v", query, gotEarliest, gotFound, wantEarliest, foundEarliest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerContainsByString(container, descendant string) bool {
|
||||||
|
if container == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return descendant == container || strings.HasPrefix(descendant, container+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedPrefixIDs(ids []int) []int {
|
||||||
|
result := slices.Clone(ids)
|
||||||
|
slices.Sort(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestPathRoundTripsSemanticKeys(t *testing.T) {
|
func TestPathRoundTripsSemanticKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -36,6 +39,73 @@ func TestPathRoundTripsSemanticKeys(t *testing.T) {
|
|||||||
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
|
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
|
||||||
t.Fatalf("indexed path = %q", got)
|
t.Fatalf("indexed path = %q", got)
|
||||||
}
|
}
|
||||||
|
maximum := int(^uint(0) >> 1)
|
||||||
|
for _, index := range []int{100, maximum, -1, -maximum - 1} {
|
||||||
|
want := "/items/" + strconv.Itoa(index)
|
||||||
|
if got := Keys("items").Index(index).String(); got != want {
|
||||||
|
t.Fatalf("indexed path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathContainmentMatchesPointerSpelling(t *testing.T) {
|
||||||
|
parse := func(pointer string) Path {
|
||||||
|
path, err := ParsePath(pointer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePath(%q) error = %v", pointer, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
maximum := int(^uint(0) >> 1)
|
||||||
|
minimum := -maximum - 1
|
||||||
|
paths := []Path{
|
||||||
|
{},
|
||||||
|
Keys(""),
|
||||||
|
Keys("a"),
|
||||||
|
Keys("ab"),
|
||||||
|
Keys("a", "b"),
|
||||||
|
Keys("a/b"),
|
||||||
|
Keys("a~1b"),
|
||||||
|
Keys("~"),
|
||||||
|
Keys("0"),
|
||||||
|
Keys("01"),
|
||||||
|
Keys("+1"),
|
||||||
|
Keys("items").Index(0),
|
||||||
|
Keys("items").Index(maximum),
|
||||||
|
Keys("items").Index(minimum),
|
||||||
|
parse("/"),
|
||||||
|
parse("/a~1b"),
|
||||||
|
parse("/a~01b"),
|
||||||
|
parse("/~0"),
|
||||||
|
parse("/0"),
|
||||||
|
parse("/01"),
|
||||||
|
parse("/+1"),
|
||||||
|
parse("/items/0"),
|
||||||
|
parse("/items/" + strconv.Itoa(maximum)),
|
||||||
|
parse("/items/" + strconv.Itoa(minimum)),
|
||||||
|
Path{}.Index(-1),
|
||||||
|
}
|
||||||
|
for _, container := range paths {
|
||||||
|
for _, descendant := range paths {
|
||||||
|
got := pathContains(container, descendant)
|
||||||
|
want := pathContainsByPointer(container, descendant)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("pathContains(%q, %q) = %v, want %v", container.String(), descendant.String(), got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathContainsByPointer(container, descendant Path) bool {
|
||||||
|
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prefix := container.String()
|
||||||
|
if prefix == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value := descendant.String()
|
||||||
|
return value == prefix || len(value) > len(prefix) && value[:len(prefix)] == prefix && value[len(prefix)] == '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) {
|
func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) {
|
||||||
|
|||||||
@@ -357,6 +357,21 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareConfigurationValuesSortEscapedPointers(t *testing.T) {
|
||||||
|
before := parseCompositionDocument(t, "before", `values: {z: old, 'a/b': old, 'a~b': old, nested: {x: old}}`)
|
||||||
|
after := parseCompositionDocument(t, "after", `values: {z: new, 'a/b': new, 'a~b': new, nested: {x: new}}`)
|
||||||
|
deltas := compareConfigurationValues(before, after)
|
||||||
|
want := []string{"/values/a~0b", "/values/a~1b", "/values/nested/x", "/values/z"}
|
||||||
|
if len(deltas) != len(want) {
|
||||||
|
t.Fatalf("deltas = %#v", deltas)
|
||||||
|
}
|
||||||
|
for i, delta := range deltas {
|
||||||
|
if got := delta.path.String(); got != want[i] {
|
||||||
|
t.Fatalf("delta[%d] path = %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
|
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
schema, issue := testBuildSchema(reflect.TypeFor[T]())
|
schema, issue := testBuildSchema(reflect.TypeFor[T]())
|
||||||
|
|||||||
@@ -26,6 +26,37 @@ type resolutionRecord struct {
|
|||||||
reasons []string
|
reasons []string
|
||||||
dependencies []Path
|
dependencies []Path
|
||||||
helperExpected *document.Node
|
helperExpected *document.Node
|
||||||
|
reasonSet map[string]struct{}
|
||||||
|
dependencySet map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (record *resolutionRecord) addReason(reason string) {
|
||||||
|
if record.reasonSet == nil {
|
||||||
|
record.reasonSet = make(map[string]struct{}, len(record.reasons)+1)
|
||||||
|
for _, existing := range record.reasons {
|
||||||
|
record.reasonSet[existing] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, found := record.reasonSet[reason]; found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record.reasonSet[reason] = struct{}{}
|
||||||
|
record.reasons = append(record.reasons, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (record *resolutionRecord) addDependency(path Path) {
|
||||||
|
pointer := path.String()
|
||||||
|
if record.dependencySet == nil {
|
||||||
|
record.dependencySet = make(map[string]struct{}, len(record.dependencies)+1)
|
||||||
|
for _, existing := range record.dependencies {
|
||||||
|
record.dependencySet[existing.String()] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, found := record.dependencySet[pointer]; found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record.dependencySet[pointer] = struct{}{}
|
||||||
|
record.dependencies = append(record.dependencies, path.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
type resolutionSnapshot struct {
|
type resolutionSnapshot struct {
|
||||||
@@ -207,9 +238,9 @@ func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason strin
|
|||||||
if kind == ChangeNormalization {
|
if kind == ChangeNormalization {
|
||||||
record.kind = ChangeNormalization
|
record.kind = ChangeNormalization
|
||||||
}
|
}
|
||||||
record.reasons = appendUniqueString(record.reasons, reason)
|
record.addReason(reason)
|
||||||
for _, dependency := range dependencies {
|
for _, dependency := range dependencies {
|
||||||
record.dependencies = appendUniquePath(record.dependencies, dependency)
|
record.addDependency(dependency)
|
||||||
}
|
}
|
||||||
if expected != nil {
|
if expected != nil {
|
||||||
record.helperExpected = document.Clone(expected)
|
record.helperExpected = document.Clone(expected)
|
||||||
@@ -272,14 +303,17 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
deltas := compareConfigurationValues(decoded.final, after)
|
deltas := compareConfigurationValues(decoded.final, after)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
|
coverage := newResolutionCoverageIndexFromRecords(ordered)
|
||||||
|
beforeLookup := buildDocumentLookup(decoded.final)
|
||||||
|
afterLookup := buildDocumentLookup(after)
|
||||||
for _, delta := range deltas {
|
for _, delta := range deltas {
|
||||||
if coveringResolutionRecord(ordered, delta.path) == nil {
|
if _, ok := coverage.deepestCoveringID(delta.path); !ok {
|
||||||
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
|
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
_, beforePresent := documentAtPath(decoded.final, record.path)
|
_, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
|
||||||
afterNode, afterPresent := documentAtPath(after, record.path)
|
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
|
||||||
if !beforePresent && !afterPresent {
|
if !beforePresent && !afterPresent {
|
||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
continue
|
continue
|
||||||
@@ -294,8 +328,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
|
|
||||||
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
snapshots := make([]resolutionSnapshot, 0, len(ordered))
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
beforeNode, _ := documentAtPath(decoded.final, record.path)
|
beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
|
||||||
afterNode, _ := documentAtPath(after, record.path)
|
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
|
||||||
snapshots = append(snapshots, resolutionSnapshot{
|
snapshots = append(snapshots, resolutionSnapshot{
|
||||||
change: Change{
|
change: Change{
|
||||||
Path: record.path.clone(),
|
Path: record.path.clone(),
|
||||||
@@ -313,52 +347,82 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
|
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
|
||||||
ordered := make([]*resolutionRecord, 0, len(records))
|
type orderedRecord struct {
|
||||||
for _, record := range records {
|
record *resolutionRecord
|
||||||
ordered = append(ordered, record)
|
pointer string
|
||||||
}
|
}
|
||||||
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
|
ordered := make([]orderedRecord, 0, len(records))
|
||||||
return strings.Compare(left.path.String(), right.path.String())
|
for _, record := range records {
|
||||||
|
ordered = append(ordered, orderedRecord{record: record, pointer: record.path.String()})
|
||||||
|
}
|
||||||
|
slices.SortFunc(ordered, func(left, right orderedRecord) int {
|
||||||
|
return strings.Compare(left.pointer, right.pointer)
|
||||||
})
|
})
|
||||||
return ordered
|
result := make([]*resolutionRecord, len(ordered))
|
||||||
|
for i, item := range ordered {
|
||||||
|
result[i] = item.record
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord {
|
type resolutionCoverageIndex struct {
|
||||||
var covering *resolutionRecord
|
prefix pathPrefixIndex
|
||||||
for _, record := range records {
|
depths []int
|
||||||
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
|
}
|
||||||
covering = record
|
|
||||||
|
func newResolutionCoverageIndexFromRecords(records []*resolutionRecord) *resolutionCoverageIndex {
|
||||||
|
index := &resolutionCoverageIndex{depths: make([]int, len(records))}
|
||||||
|
for id, record := range records {
|
||||||
|
index.depths[id] = len(record.path.segments)
|
||||||
|
if record.path.valid() {
|
||||||
|
index.prefix.insert(record.path.String(), id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return covering
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResolutionCoverageIndexFromSnapshots(snapshots []resolutionSnapshot) (*resolutionCoverageIndex, []string) {
|
||||||
|
index := &resolutionCoverageIndex{depths: make([]int, len(snapshots))}
|
||||||
|
pointers := make([]string, len(snapshots))
|
||||||
|
for id, snapshot := range snapshots {
|
||||||
|
path := snapshot.change.Path
|
||||||
|
index.depths[id] = len(path.segments)
|
||||||
|
pointers[id] = path.String()
|
||||||
|
if path.valid() {
|
||||||
|
index.prefix.insert(pointers[id], id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index, pointers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *resolutionCoverageIndex) deepestCoveringID(path Path) (int, bool) {
|
||||||
|
if !path.valid() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return index.deepestCoveringPointerID(path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index *resolutionCoverageIndex) deepestCoveringPointerID(pointer string) (int, bool) {
|
||||||
|
bestID, bestDepth, found := 0, -1, false
|
||||||
|
index.prefix.visitAncestors(pointer, func(id int) {
|
||||||
|
depth := index.depths[id]
|
||||||
|
if !found || depth > bestDepth || (depth == bestDepth && id < bestID) {
|
||||||
|
bestID, bestDepth, found = id, depth, true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return bestID, found
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathContains(container, descendant Path) bool {
|
func pathContains(container, descendant Path) bool {
|
||||||
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
|
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if container.String() == "" {
|
for i, segment := range container.segments {
|
||||||
return true
|
if !pathTokenSpellingEqual(segment, descendant.segments[i]) {
|
||||||
}
|
return false
|
||||||
prefix := container.String()
|
|
||||||
value := descendant.String()
|
|
||||||
return value == prefix || strings.HasPrefix(value, prefix+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendUniqueString(values []string, value string) []string {
|
|
||||||
if !slices.Contains(values, value) {
|
|
||||||
return append(values, value)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendUniquePath(paths []Path, path Path) []Path {
|
|
||||||
for _, existing := range paths {
|
|
||||||
if existing.String() == path.String() {
|
|
||||||
return paths
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return append(paths, path.clone())
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func clonePaths(paths []Path) []Path {
|
func clonePaths(paths []Path) []Path {
|
||||||
|
|||||||
247
internal/engine/resolution_index_test.go
Normal file
247
internal/engine/resolution_index_test.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCoalescedResolutionSnapshotsMatchOrderedScan(t *testing.T) {
|
||||||
|
parsedDependency := parseOverrideTestPath(t, "/dependencies/item")
|
||||||
|
snapshots := []resolutionSnapshot{
|
||||||
|
{change: Change{Path: Keys("a", "b"), Reasons: []string{"child", "shared", "child"}, Dependencies: []Path{Keys("dependencies", "item")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
{change: Change{Path: Keys("a", "c"), Reasons: []string{"other"}, Dependencies: []Path{Keys("other")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
{change: Change{Path: Keys("a"), Reasons: []string{"parent"}, Dependencies: []Path{Keys("parent")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
{change: Change{Path: Keys("a", "b", "grand"), Reasons: []string{"deep", "shared"}, Dependencies: []Path{parsedDependency, Keys("deep")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
{change: Change{Path: Path{}, Reasons: []string{"root"}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
{change: Change{Path: Keys("outside"), Reasons: []string{"root child"}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
{change: Change{Path: Keys("a", "b", "normalization"), Reasons: []string{"normalized"}, Origin: Origin{Operation: ChangeNormalization}}},
|
||||||
|
{change: Change{Path: Keys("a", "none"), Reasons: []string{"parent child"}, Origin: Origin{Operation: ChangeDerivedDefault}}},
|
||||||
|
}
|
||||||
|
want := coalescedResolutionSnapshotsByScan(snapshots)
|
||||||
|
got := coalescedResolutionSnapshots(snapshots)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("coalesced snapshots differ from ordered scan:\n got: %#v\nwant: %#v", got, want)
|
||||||
|
}
|
||||||
|
if len(got) != 4 || got[0].change.Origin.Operation != ChangeNormalization ||
|
||||||
|
!reflect.DeepEqual(got[0].change.Reasons, []string{"child", "shared", "child", "deep", "normalized"}) ||
|
||||||
|
len(got[0].change.Dependencies) != 2 || got[3].change.Path.String() != "" {
|
||||||
|
t.Fatalf("coalesced ordering or unions changed: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalescedResolutionSnapshotsByScan(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||||
|
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
merged := false
|
||||||
|
for i := range groups {
|
||||||
|
if !pathContainsByPointer(groups[i].change.Path, snapshot.change.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, reason := range snapshot.change.Reasons {
|
||||||
|
groups[i].change.Reasons = appendUniqueStringByScan(groups[i].change.Reasons, reason)
|
||||||
|
}
|
||||||
|
for _, dependency := range snapshot.change.Dependencies {
|
||||||
|
groups[i].change.Dependencies = appendUniquePathByPointer(groups[i].change.Dependencies, dependency)
|
||||||
|
}
|
||||||
|
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||||
|
groups[i].change.Origin.Operation = ChangeNormalization
|
||||||
|
}
|
||||||
|
merged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !merged {
|
||||||
|
snapshot.change = cloneChange(snapshot.change)
|
||||||
|
groups = append(groups, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueStringByScan(values []string, value string) []string {
|
||||||
|
if !slices.Contains(values, value) {
|
||||||
|
return append(values, value)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniquePathByPointer(paths []Path, path Path) []Path {
|
||||||
|
for _, existing := range paths {
|
||||||
|
if existing.String() == path.String() {
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(paths, path.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionDependencyMembershipMatchesPointerSpelling(t *testing.T) {
|
||||||
|
parse := func(pointer string) Path {
|
||||||
|
path, err := ParsePath(pointer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePath(%q) error = %v", pointer, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
paths := []Path{
|
||||||
|
Keys("a/b"), parse("/a~1b"),
|
||||||
|
Keys("a~1b"), parse("/a~01b"),
|
||||||
|
Keys("items").Index(0), parse("/items/0"), Keys("items", "0"),
|
||||||
|
Keys("items", "01"), parse("/items/01"),
|
||||||
|
Keys("-1"), parse("/-1"),
|
||||||
|
Keys("a", "b"), Keys("a/b"),
|
||||||
|
}
|
||||||
|
var record resolutionRecord
|
||||||
|
var want []Path
|
||||||
|
for _, path := range paths {
|
||||||
|
record.addDependency(path)
|
||||||
|
want = appendUniquePathByPointer(want, path)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(record.dependencies, want) {
|
||||||
|
t.Fatalf("resolution dependencies = %#v, want %#v", record.dependencies, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionCoverageIndexMatchesOrderedScan(t *testing.T) {
|
||||||
|
paths := []Path{
|
||||||
|
Keys("group"),
|
||||||
|
Keys("group", "field"),
|
||||||
|
Keys("group", "field"),
|
||||||
|
Keys("group", "field", "nested"),
|
||||||
|
Keys("other"),
|
||||||
|
}
|
||||||
|
records := make([]*resolutionRecord, len(paths))
|
||||||
|
snapshots := make([]resolutionSnapshot, len(paths))
|
||||||
|
for id, path := range paths {
|
||||||
|
records[id] = &resolutionRecord{path: path}
|
||||||
|
snapshots[id] = resolutionSnapshot{change: Change{Path: path}}
|
||||||
|
}
|
||||||
|
snapshotIndex, pointers := newResolutionCoverageIndexFromSnapshots(snapshots)
|
||||||
|
for id, pointer := range pointers {
|
||||||
|
if pointer != paths[id].String() {
|
||||||
|
t.Errorf("snapshot pointer %d = %q, want %q", id, pointer, paths[id].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queries := []Path{
|
||||||
|
Path{},
|
||||||
|
Keys("group"),
|
||||||
|
Keys("group", "field"),
|
||||||
|
Keys("group", "field", "nested", "leaf"),
|
||||||
|
Keys("group", "missing"),
|
||||||
|
Keys("other", "child"),
|
||||||
|
Keys("group").Index(-1),
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
index *resolutionCoverageIndex
|
||||||
|
}{
|
||||||
|
{"records", newResolutionCoverageIndexFromRecords(records)},
|
||||||
|
{"snapshots", snapshotIndex},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
for _, query := range queries {
|
||||||
|
wantID, wantDepth, wantFound := 0, -1, false
|
||||||
|
for id, candidate := range paths {
|
||||||
|
if pathContainsByPointer(candidate, query) && (!wantFound || len(candidate.segments) > wantDepth) {
|
||||||
|
wantID, wantDepth, wantFound = id, len(candidate.segments), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gotID, gotFound := test.index.deepestCoveringID(query)
|
||||||
|
if gotID != wantID || gotFound != wantFound {
|
||||||
|
t.Errorf("deepestCoveringID(%s) = %d, %v; want %d, %v", query.String(), gotID, gotFound, wantID, wantFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentLookupMatchesPathScan(t *testing.T) {
|
||||||
|
name := &document.Node{Kind: document.String, Text: "item"}
|
||||||
|
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{
|
||||||
|
{Kind: document.String, Text: "zero"},
|
||||||
|
{Kind: document.Mapping, Mapping: []document.Entry{{Key: "name", Value: name}}},
|
||||||
|
}}
|
||||||
|
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||||
|
{Key: "0", Value: &document.Node{Kind: document.String, Text: "numeric key"}},
|
||||||
|
{Key: "01", Value: &document.Node{Kind: document.String, Text: "leading zero key"}},
|
||||||
|
{Key: "items", Value: sequence},
|
||||||
|
}}
|
||||||
|
applicationRoot := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
|
||||||
|
{Key: "shared", Value: sequence},
|
||||||
|
{Key: "application", Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "application"}}}}},
|
||||||
|
}}
|
||||||
|
lookup := buildDocumentLookup(root, applicationRoot)
|
||||||
|
paths := []Path{
|
||||||
|
Path{},
|
||||||
|
Keys("0"),
|
||||||
|
Keys("01"),
|
||||||
|
Keys("items"),
|
||||||
|
Keys("items").Index(0),
|
||||||
|
Keys("items").Index(1).Key("name"),
|
||||||
|
Keys("items").Index(2),
|
||||||
|
Keys("items").Key("name"),
|
||||||
|
Keys("missing"),
|
||||||
|
Keys("items").Index(-1),
|
||||||
|
(Path{}).Index(0),
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
wantNode, wantFound := documentAtPath(root, path)
|
||||||
|
gotNode, gotFound := documentAtPathWithLookup(root, path, lookup)
|
||||||
|
if gotNode != wantNode || gotFound != wantFound {
|
||||||
|
t.Errorf("indexed lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []Path{Path{}, Keys("shared"), Keys("shared").Index(1).Key("name"), Keys("application", "value")} {
|
||||||
|
wantNode, wantFound := documentAtPath(applicationRoot, path)
|
||||||
|
gotNode, gotFound := documentAtPathWithLookup(applicationRoot, path, lookup)
|
||||||
|
if gotNode != wantNode || gotFound != wantFound {
|
||||||
|
t.Errorf("indexed application lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyResolutionMetadataPreservesOverlappingDeltaOrder(t *testing.T) {
|
||||||
|
oldOrigin := Origin{Layer: LayerBase}
|
||||||
|
parentOrigin := Origin{Layer: LayerResolution, Operation: ChangeNormalization}
|
||||||
|
childOrigin := Origin{Layer: LayerResolution, Operation: ChangeDerivedDefault}
|
||||||
|
leaf := &document.Node{Kind: document.String, Text: "value"}
|
||||||
|
final := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "group",
|
||||||
|
Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
|
||||||
|
Key: "item",
|
||||||
|
Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "leaf", Value: leaf}}},
|
||||||
|
}}},
|
||||||
|
}}}
|
||||||
|
metadata := &Metadata{
|
||||||
|
fields: map[string]FieldInfo{"/group/item/leaf": {Origin: oldOrigin}},
|
||||||
|
history: make(map[string][]Change),
|
||||||
|
}
|
||||||
|
resolved := resolutionResult{
|
||||||
|
final: final,
|
||||||
|
deltas: []documentDelta{{path: Keys("group")}, {path: Keys("group", "item")}},
|
||||||
|
snapshots: []resolutionSnapshot{
|
||||||
|
{change: Change{Path: Keys("group"), Origin: parentOrigin}},
|
||||||
|
{change: Change{Path: Keys("group", "item"), Origin: childOrigin}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyResolutionMetadata(metadata, resolved)
|
||||||
|
|
||||||
|
field := metadata.fields["/group/item/leaf"]
|
||||||
|
if field.Origin.Operation != childOrigin.Operation {
|
||||||
|
t.Fatalf("final leaf origin operation = %q, want %q", field.Origin.Operation, childOrigin.Operation)
|
||||||
|
}
|
||||||
|
changes := metadata.history["/group/item/leaf"]
|
||||||
|
if len(changes) != 2 {
|
||||||
|
t.Fatalf("leaf history has %d entries, want 2: %#v", len(changes), changes)
|
||||||
|
}
|
||||||
|
if changes[0].Origin.Operation != parentOrigin.Operation || changes[1].Origin.Operation != childOrigin.Operation {
|
||||||
|
t.Fatalf("leaf history operations = %q, %q; want %q, %q", changes[0].Origin.Operation, changes[1].Origin.Operation, parentOrigin.Operation, childOrigin.Operation)
|
||||||
|
}
|
||||||
|
for index, change := range changes {
|
||||||
|
if len(change.Previous) != 1 || change.Previous[0].Layer != oldOrigin.Layer {
|
||||||
|
t.Errorf("leaf history[%d] Previous = %#v, want original origin", index, change.Previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,11 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
|
|||||||
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
|
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
parsedOther, err := ParsePath("/other")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Value int `yaml:"value"`
|
Value int `yaml:"value"`
|
||||||
Other int `yaml:"other"`
|
Other int `yaml:"other"`
|
||||||
@@ -130,8 +135,9 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
|
|||||||
Defaults: func() config { return config{Value: 5} },
|
Defaults: func() config { return config{Value: 5} },
|
||||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||||
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
|
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
|
||||||
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", Keys("other"))
|
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", parsedOther)
|
||||||
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
|
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
|
||||||
|
Normalize(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", parsedOther)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -206,6 +212,31 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrderedResolutionRecordsSortEscapedPointers(t *testing.T) {
|
||||||
|
parsedIndex, err := ParsePath("/items/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
records := map[string]*resolutionRecord{
|
||||||
|
"tilde": {path: Keys("a~b")},
|
||||||
|
"slash": {path: Keys("a/b")},
|
||||||
|
"nested": {path: Keys("a", "b")},
|
||||||
|
"index": {path: Keys("items").Index(0)},
|
||||||
|
"parsed": {path: parsedIndex},
|
||||||
|
"last": {path: Keys("z")},
|
||||||
|
}
|
||||||
|
ordered := orderedResolutionRecords(records)
|
||||||
|
want := []string{"/a/b", "/a~0b", "/a~1b", "/items/0", "/items/0", "/z"}
|
||||||
|
if len(ordered) != len(want) {
|
||||||
|
t.Fatalf("ordered records = %d, want %d", len(ordered), len(want))
|
||||||
|
}
|
||||||
|
for i, record := range ordered {
|
||||||
|
if got := record.path.String(); got != want[i] {
|
||||||
|
t.Fatalf("record[%d] path = %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
|
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,10 @@ type secretDependency struct {
|
|||||||
|
|
||||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
||||||
paths := make(map[string]Path)
|
paths := make(map[string]Path)
|
||||||
finalPaths := make(map[string]*document.Node)
|
|
||||||
indexDocument(final, Path{}, finalPaths)
|
|
||||||
for pointer := range finalPaths {
|
|
||||||
addSecretPath(paths, pointer)
|
|
||||||
}
|
|
||||||
for _, application := range applications {
|
for _, application := range applications {
|
||||||
applicationPaths := make(map[string]*document.Node)
|
walkDocument(application.value, application.path, func(path Path, _ *document.Node) {
|
||||||
indexDocument(application.value, application.path, applicationPaths)
|
addSecretPath(paths, path.String())
|
||||||
for pointer := range applicationPaths {
|
})
|
||||||
addSecretPath(paths, pointer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var dependencies []secretDependency
|
var dependencies []secretDependency
|
||||||
for pointer, history := range metadata.history {
|
for pointer, history := range metadata.history {
|
||||||
@@ -65,16 +58,19 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
}
|
}
|
||||||
|
|
||||||
secret := make(map[string]bool)
|
secret := make(map[string]bool)
|
||||||
for _, tree := range append(projections, final) {
|
classifyProjection := func(tree *document.Node) {
|
||||||
projected := make(map[string]*document.Node)
|
walkDocument(tree, Path{}, func(path Path, node *document.Node) {
|
||||||
indexDocument(tree, Path{}, projected)
|
pointer := path.String()
|
||||||
for pointer, node := range projected {
|
|
||||||
addSecretPath(paths, pointer)
|
addSecretPath(paths, pointer)
|
||||||
if node.Sensitive {
|
if node.Sensitive {
|
||||||
secret[pointer] = true
|
secret[pointer] = true
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
for _, tree := range projections {
|
||||||
|
classifyProjection(tree)
|
||||||
|
}
|
||||||
|
classifyProjection(final)
|
||||||
for pointer, path := range paths {
|
for pointer, path := range paths {
|
||||||
if root, ok := schemaSecretRoot(schema, path); ok {
|
if root, ok := schemaSecretRoot(schema, path); ok {
|
||||||
secret[root.String()] = true
|
secret[root.String()] = true
|
||||||
@@ -83,49 +79,29 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
secret[pointer] = true
|
secret[pointer] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for {
|
return closeSecretDependencies(paths, secret, dependencies, inherited)
|
||||||
changed := false
|
|
||||||
for _, dependency := range dependencies {
|
|
||||||
if secretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
|
|
||||||
secret[dependency.target.String()] = true
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, link := range inherited {
|
|
||||||
if pathIsSecret(secret, link.dependency) && !secret[link.target.String()] {
|
|
||||||
secret[link.target.String()] = true
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if pathIsSecret(secret, link.target) && !secret[link.dependency.String()] {
|
|
||||||
secret[link.dependency.String()] = true
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
return secret
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSecretPath(paths map[string]Path, pointer string) {
|
func addSecretPath(paths map[string]Path, pointer string) {
|
||||||
|
if existing, found := paths[pointer]; found && existing.valid() {
|
||||||
|
// Accumulator keys already match their paths' canonical spelling. History
|
||||||
|
// can install typed segments, which still need the parsed replacement.
|
||||||
|
parsed := true
|
||||||
|
for _, segment := range existing.segments {
|
||||||
|
if segment.kind != pathSegmentParsed {
|
||||||
|
parsed = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if path, err := ParsePath(pointer); err == nil {
|
if path, err := ParsePath(pointer); err == nil {
|
||||||
paths[pointer] = path
|
paths[pointer] = path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func secretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
|
|
||||||
for pointer := range secret {
|
|
||||||
classified, ok := paths[pointer]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if pathContains(classified, candidate) || pathContains(candidate, classified) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
|
func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
|
||||||
if schema == nil || schema.root == nil || !path.valid() {
|
if schema == nil || schema.root == nil || !path.valid() {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
|
|||||||
79
internal/engine/secret_classification_test.go
Normal file
79
internal/engine/secret_classification_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
|
||||||
|
for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} {
|
||||||
|
t.Run(pointer, func(t *testing.T) {
|
||||||
|
paths := make(map[string]Path)
|
||||||
|
for repeat := 0; repeat < 3; repeat++ {
|
||||||
|
addSecretPath(paths, pointer)
|
||||||
|
path, found := paths[pointer]
|
||||||
|
if !found || !path.valid() || path.String() != pointer {
|
||||||
|
t.Fatalf("collected path = %#v, found %v; want %q", path, found, pointer)
|
||||||
|
}
|
||||||
|
for _, segment := range path.segments {
|
||||||
|
if segment.kind != pathSegmentParsed {
|
||||||
|
t.Fatalf("collected path %q retained a typed segment: %#v", pointer, segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretClassificationParsesTypedHistoryAfterNumericShapeChanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
earlier string
|
||||||
|
final string
|
||||||
|
historical Path
|
||||||
|
}{
|
||||||
|
{"mapping to sequence", "items: {'0': old}", "items: [new]", Keys("items", "0")},
|
||||||
|
{"sequence to mapping", "items: [old]", "items: {'0': new}", Keys("items").Index(0)},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
earlier := parseCompositionDocument(t, "earlier", test.earlier)
|
||||||
|
final := parseCompositionDocument(t, "final", test.final)
|
||||||
|
pointer := test.historical.String()
|
||||||
|
metadata := Metadata{history: map[string][]Change{pointer: {{Path: test.historical}}}}
|
||||||
|
seen := false
|
||||||
|
classified := classifySecretPaths(nil, func(path Path) bool {
|
||||||
|
if path.String() != pointer {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen = true
|
||||||
|
for _, segment := range path.segments {
|
||||||
|
if segment.kind != pathSegmentParsed {
|
||||||
|
t.Fatalf("predicate received typed history after final projection: %#v", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, metadata, final, []historyApplication{{value: earlier}}, earlier)
|
||||||
|
if !seen || !classified[pointer] {
|
||||||
|
t.Fatalf("historical/final numeric path was not classified: %#v", classified)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecretPredicateReceivesDetachedHistoricalPath(t *testing.T) {
|
||||||
|
path := Keys("records").Key("1")
|
||||||
|
metadata := Metadata{history: map[string][]Change{
|
||||||
|
path.String(): {{Path: path}},
|
||||||
|
}}
|
||||||
|
seen := false
|
||||||
|
classified := classifySecretPaths(nil, func(candidate Path) bool {
|
||||||
|
if candidate.String() != path.String() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen = true
|
||||||
|
candidate.segments[0].key = "changed"
|
||||||
|
return true
|
||||||
|
}, metadata, nil, nil)
|
||||||
|
|
||||||
|
if !seen || !classified[path.String()] || classified["/changed/1"] {
|
||||||
|
t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/engine/secret_propagation.go
Normal file
69
internal/engine/secret_propagation.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
type secretTrigger struct {
|
||||||
|
target string
|
||||||
|
ordinary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
|
||||||
|
result := make(map[string]bool, len(sensitive))
|
||||||
|
queue := make([]string, 0, len(sensitive))
|
||||||
|
for pointer, classified := range sensitive {
|
||||||
|
if classified {
|
||||||
|
result[pointer] = true
|
||||||
|
queue = append(queue, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var triggers pathPrefixIndex
|
||||||
|
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
|
||||||
|
insert := func(dependency Path, target string, ordinary bool) {
|
||||||
|
if ordinary && !dependency.valid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := len(triggerValues)
|
||||||
|
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
|
||||||
|
triggers.insert(dependency.String(), id)
|
||||||
|
}
|
||||||
|
for _, dependency := range dependencies {
|
||||||
|
insert(dependency.dependency, dependency.target.String(), true)
|
||||||
|
}
|
||||||
|
for _, link := range inherited {
|
||||||
|
insert(link.dependency, link.target.String(), false)
|
||||||
|
insert(link.target, link.dependency.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fired := make([]bool, len(triggerValues))
|
||||||
|
activate := func(id int) {
|
||||||
|
if fired[id] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fired[id] = true
|
||||||
|
target := triggerValues[id].target
|
||||||
|
if !result[target] {
|
||||||
|
result[target] = true
|
||||||
|
queue = append(queue, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for head := 0; head < len(queue); head++ {
|
||||||
|
pointer := queue[head]
|
||||||
|
if classified, ok := paths[pointer]; ok && classified.valid() {
|
||||||
|
triggers.visitAncestors(classified.String(), func(id int) {
|
||||||
|
if triggerValues[id].ordinary {
|
||||||
|
activate(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
triggers.visitDescendants(classified.String(), func(id int) {
|
||||||
|
if triggerValues[id].ordinary {
|
||||||
|
activate(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
triggers.visitDescendants(pointer, func(id int) {
|
||||||
|
if !triggerValues[id].ordinary {
|
||||||
|
activate(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
147
internal/engine/secret_propagation_test.go
Normal file
147
internal/engine/secret_propagation_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSecretDependencyClosureMatchesFixedPointScan(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
paths map[string]Path
|
||||||
|
seed map[string]bool
|
||||||
|
dependencies []secretDependency
|
||||||
|
inherited []secretDependency
|
||||||
|
wantSensitivity map[string]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "forward reverse chains and cycles",
|
||||||
|
paths: secretPaths(Keys("input"), Keys("derived"), Keys("output"), Keys("cycle")),
|
||||||
|
seed: secretSet(Keys("input")),
|
||||||
|
dependencies: []secretDependency{
|
||||||
|
{dependency: Keys("input"), target: Keys("derived")},
|
||||||
|
{dependency: Keys("derived"), target: Keys("output")},
|
||||||
|
{dependency: Keys("output"), target: Keys("cycle")},
|
||||||
|
{dependency: Keys("cycle"), target: Keys("derived")},
|
||||||
|
{dependency: Keys("input"), target: Keys("derived")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ordinary ancestor descendant and root relations",
|
||||||
|
paths: secretPaths(Path{}, Keys("branch", "leaf"), Keys("parent", "child")),
|
||||||
|
seed: secretSet(Keys("branch", "leaf"), Keys("parent")),
|
||||||
|
dependencies: []secretDependency{
|
||||||
|
{dependency: Keys("branch"), target: Keys("from-descendant")},
|
||||||
|
{dependency: Keys("parent", "child"), target: Keys("from-ancestor")},
|
||||||
|
{dependency: Path{}, target: Keys("from-root")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unindexed seed only activates inherited relations",
|
||||||
|
paths: secretPaths(Keys("ordinary"), Keys("copy")),
|
||||||
|
seed: secretSet(Keys("untracked")),
|
||||||
|
dependencies: []secretDependency{
|
||||||
|
{dependency: Keys("untracked"), target: Keys("ordinary")},
|
||||||
|
},
|
||||||
|
inherited: []secretDependency{
|
||||||
|
{dependency: Keys("untracked"), target: Keys("copy")},
|
||||||
|
},
|
||||||
|
wantSensitivity: map[string]bool{"/copy": true, "/ordinary": false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inheritance direction root escapes and numeric spellings",
|
||||||
|
paths: secretPaths(Path{}, Keys("a/b", "01"), Keys("source", "value")),
|
||||||
|
seed: secretSet(Path{}),
|
||||||
|
dependencies: []secretDependency{
|
||||||
|
{dependency: Keys("a/b", "01"), target: Keys("ordinary-copy")},
|
||||||
|
},
|
||||||
|
inherited: []secretDependency{
|
||||||
|
{dependency: Keys("a/b", "01"), target: Keys("destination", "01")},
|
||||||
|
{dependency: Keys("source"), target: Keys("destination")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inherited links do not trigger from classified descendants",
|
||||||
|
paths: secretPaths(Keys("parent", "secret", "nested")),
|
||||||
|
seed: secretSet(Keys("parent", "secret", "nested")),
|
||||||
|
inherited: []secretDependency{
|
||||||
|
{dependency: Keys("parent", "secret"), target: Keys("child", "secret")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
want := referenceSecretDependencyClosure(test.paths, test.seed, test.dependencies, test.inherited)
|
||||||
|
got := closeSecretDependencies(test.paths, test.seed, test.dependencies, test.inherited)
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("closure = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for pointer, sensitive := range test.wantSensitivity {
|
||||||
|
if got[pointer] != sensitive {
|
||||||
|
t.Errorf("sensitivity at %q = %v, want %v", pointer, got[pointer], sensitive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceSecretDependencyClosure(paths map[string]Path, seed map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
|
||||||
|
secret := make(map[string]bool, len(seed))
|
||||||
|
for pointer, classified := range seed {
|
||||||
|
if classified {
|
||||||
|
secret[pointer] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
changed := false
|
||||||
|
for _, dependency := range dependencies {
|
||||||
|
if referenceSecretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
|
||||||
|
secret[dependency.target.String()] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, link := range inherited {
|
||||||
|
if pathIsSecret(secret, link.dependency) && !secret[link.target.String()] {
|
||||||
|
secret[link.target.String()] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if pathIsSecret(secret, link.target) && !secret[link.dependency.String()] {
|
||||||
|
secret[link.dependency.String()] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceSecretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
|
||||||
|
for pointer := range secret {
|
||||||
|
classified, ok := paths[pointer]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pathContains(classified, candidate) || pathContains(candidate, classified) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretPaths(paths ...Path) map[string]Path {
|
||||||
|
result := make(map[string]Path, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
result[path.String()] = path
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretSet(paths ...Path) map[string]bool {
|
||||||
|
result := make(map[string]bool, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
result[path.String()] = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -12,7 +12,11 @@ import (
|
|||||||
// Snapshot projection is the shared redaction boundary for effective YAML
|
// Snapshot projection is the shared redaction boundary for effective YAML
|
||||||
// and explanation values. It borrows frozen trees and returns a detached tree.
|
// and explanation values. It borrows frozen trees and returns a detached tree.
|
||||||
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
||||||
node, present := documentAtPath(root, path)
|
return valueSnapshotWithLookup(root, path, secrets, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueSnapshotWithLookup(root *document.Node, path Path, secrets map[string]bool, lookup *documentLookup) ValueSnapshot {
|
||||||
|
node, present := documentAtPathWithLookup(root, path, lookup)
|
||||||
if !present {
|
if !present {
|
||||||
return ValueSnapshot{}
|
return ValueSnapshot{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type releaseFixture struct {
|
|
||||||
t *testing.T
|
|
||||||
root string
|
|
||||||
remote string
|
|
||||||
base string
|
|
||||||
candidate string
|
|
||||||
validationLog string
|
|
||||||
realGit string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReleaseFixture(t *testing.T) *releaseFixture {
|
|
||||||
t.Helper()
|
|
||||||
realGit, err := exec.LookPath("git")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
f := &releaseFixture{
|
|
||||||
t: t,
|
|
||||||
root: t.TempDir(),
|
|
||||||
remote: filepath.Join(t.TempDir(), "remote.git"),
|
|
||||||
validationLog: filepath.Join(t.TempDir(), "validator.log"),
|
|
||||||
realGit: realGit,
|
|
||||||
}
|
|
||||||
f.git("init", "-q", "-b", "main")
|
|
||||||
f.git("init", "-q", "--bare", f.remote)
|
|
||||||
f.git("config", "user.name", "Test")
|
|
||||||
f.git("config", "user.email", "test@example.invalid")
|
|
||||||
f.git("config", "commit.gpgsign", "false")
|
|
||||||
f.git("config", "tag.gpgsign", "false")
|
|
||||||
f.git("config", "push.followTags", "true")
|
|
||||||
f.git("remote", "add", "origin", f.remote)
|
|
||||||
f.write(".gitignore", "ignored.log\n", 0600)
|
|
||||||
f.write("README.md", "fixture\n", 0600)
|
|
||||||
f.git("add", ".")
|
|
||||||
f.git("commit", "-qm", "Initial source")
|
|
||||||
f.base = f.git("rev-parse", "HEAD")
|
|
||||||
f.git("push", f.remote, "HEAD:refs/heads/main")
|
|
||||||
|
|
||||||
script, err := os.ReadFile("scripts/release.sh")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Only the fixture copy substitutes the authoritative endpoint.
|
|
||||||
local := strings.ReplaceAll(string(script), "https://gitea.maximumdirect.net/eric/ConfigKit.git", f.remote)
|
|
||||||
f.write("scripts/release.sh", local, 0700)
|
|
||||||
f.write("scripts/release-check.sh", `#!/bin/sh
|
|
||||||
printf 'validate\n' >> "$CONFIGKIT_VALIDATION_LOG"
|
|
||||||
case "${CONFIGKIT_VALIDATOR_ACTION-}" in
|
|
||||||
fail) exit 37 ;;
|
|
||||||
change) git commit --allow-empty -qm 'Changed during validation' ;;
|
|
||||||
dirty) printf 'changed\n' > unexpected.txt ;;
|
|
||||||
remote-tag) git --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE" ;;
|
|
||||||
esac
|
|
||||||
`, 0700)
|
|
||||||
f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\nConfiguration loading fixes.\n\nCompatibility is unchanged.\n", 0600)
|
|
||||||
f.git("add", ".")
|
|
||||||
f.git("commit", "-qm", "Prepared source")
|
|
||||||
f.candidate = f.git("rev-parse", "HEAD")
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *releaseFixture) write(path, contents string, mode os.FileMode) {
|
|
||||||
f.t.Helper()
|
|
||||||
target := filepath.Join(f.root, path)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
|
||||||
f.t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(target, []byte(contents), mode); err != nil {
|
|
||||||
f.t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *releaseFixture) git(args ...string) string {
|
|
||||||
f.t.Helper()
|
|
||||||
cmd := exec.Command(f.realGit, args...)
|
|
||||||
cmd.Dir = f.root
|
|
||||||
cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
f.t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *releaseFixture) gitResult(args ...string) (string, error) {
|
|
||||||
f.t.Helper()
|
|
||||||
cmd := exec.Command(f.realGit, args...)
|
|
||||||
cmd.Dir = f.root
|
|
||||||
cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
return strings.TrimSpace(string(out)), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *releaseFixture) run(version, behavior, validatorAction string) (string, error) {
|
|
||||||
f.t.Helper()
|
|
||||||
bin := f.t.TempDir()
|
|
||||||
wrapper := `#!/bin/sh
|
|
||||||
if [ "${CONFIGKIT_GIT_BEHAVIOR-}" = main-unknown ] || [ "${CONFIGKIT_GIT_BEHAVIOR-}" = tag-unknown ]; then
|
|
||||||
if [ "${1-}" = ls-remote ] && [ -f "$CONFIGKIT_GIT_MARKER" ]; then exit 37; fi
|
|
||||||
fi
|
|
||||||
case "$*" in
|
|
||||||
push\ *|-c\ remote.origin.mirror=false\ push\ *)
|
|
||||||
case "$*" in
|
|
||||||
*refs/heads/main*)
|
|
||||||
case "${CONFIGKIT_GIT_BEHAVIOR-}" in
|
|
||||||
main-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;;
|
|
||||||
main-reject) exit 37 ;;
|
|
||||||
main-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;;
|
|
||||||
esac ;;
|
|
||||||
*refs/tags/v0.9.0*)
|
|
||||||
case "${CONFIGKIT_GIT_BEHAVIOR-}" in
|
|
||||||
tag-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;;
|
|
||||||
tag-reject) exit 37 ;;
|
|
||||||
tag-conflict)
|
|
||||||
"$CONFIGKIT_REAL_GIT" --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE"
|
|
||||||
exit 37 ;;
|
|
||||||
tag-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;;
|
|
||||||
esac ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
exec "$CONFIGKIT_REAL_GIT" "$@"
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
|
|
||||||
f.t.Fatal(err)
|
|
||||||
}
|
|
||||||
nested := filepath.Join(f.root, "nested")
|
|
||||||
if err := os.Mkdir(nested, 0700); err != nil {
|
|
||||||
f.t.Fatal(err)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("/bin/bash", filepath.Join(f.root, "scripts", "release.sh"), version)
|
|
||||||
cmd.Dir = nested
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
||||||
"GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
|
|
||||||
"CONFIGKIT_VALIDATION_LOG="+f.validationLog,
|
|
||||||
"CONFIGKIT_VALIDATOR_ACTION="+validatorAction,
|
|
||||||
"CONFIGKIT_GIT_BEHAVIOR="+behavior,
|
|
||||||
"CONFIGKIT_GIT_MARKER="+filepath.Join(bin, "push-seen"),
|
|
||||||
"CONFIGKIT_REAL_GIT="+f.realGit,
|
|
||||||
"CONFIGKIT_REMOTE="+f.remote,
|
|
||||||
"CONFIGKIT_BASE="+f.base,
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *releaseFixture) validations() int {
|
|
||||||
f.t.Helper()
|
|
||||||
data, err := os.ReadFile(f.validationLog)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
f.t.Fatal(err)
|
|
||||||
}
|
|
||||||
return strings.Count(string(data), "validate\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *releaseFixture) remoteRef(ref string) string {
|
|
||||||
f.t.Helper()
|
|
||||||
value, err := f.gitResult("--git-dir="+f.remote, "rev-parse", "--verify", ref)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReleasePublicationGuards(t *testing.T) {
|
|
||||||
for _, scenario := range []string{
|
|
||||||
"success", "main-already-current", "ignored-file", "unrelated-tag",
|
|
||||||
"malformed-version", "wrong-branch", "wrong-destination", "multiple-destinations",
|
|
||||||
"dirty-worktree", "changed-candidate", "missing-note", "mismatched-note",
|
|
||||||
"local-tag", "remote-tag", "remote-tag-after-validation",
|
|
||||||
"dirty-after-validation", "remote-query-failure",
|
|
||||||
"failed-validation", "non-fast-forward",
|
|
||||||
} {
|
|
||||||
t.Run(scenario, func(t *testing.T) {
|
|
||||||
f := newReleaseFixture(t)
|
|
||||||
version := "v0.9.0"
|
|
||||||
validatorAction := ""
|
|
||||||
switch scenario {
|
|
||||||
case "main-already-current":
|
|
||||||
f.git("push", f.remote, "HEAD:refs/heads/main")
|
|
||||||
case "ignored-file":
|
|
||||||
f.write("ignored.log", "local only\n", 0600)
|
|
||||||
case "unrelated-tag":
|
|
||||||
f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base)
|
|
||||||
case "malformed-version":
|
|
||||||
version = "v00.9.0"
|
|
||||||
case "wrong-branch":
|
|
||||||
f.git("checkout", "-qb", "feature")
|
|
||||||
case "wrong-destination":
|
|
||||||
other := filepath.Join(t.TempDir(), "other.git")
|
|
||||||
f.git("init", "-q", "--bare", other)
|
|
||||||
f.git("remote", "set-url", "--push", "origin", other)
|
|
||||||
case "multiple-destinations":
|
|
||||||
f.git("remote", "set-url", "--push", "origin", f.remote)
|
|
||||||
f.git("remote", "set-url", "--add", "--push", "origin", filepath.Join(t.TempDir(), "other.git"))
|
|
||||||
case "dirty-worktree":
|
|
||||||
f.write("untracked.txt", "unexpected\n", 0600)
|
|
||||||
case "changed-candidate":
|
|
||||||
validatorAction = "change"
|
|
||||||
case "dirty-after-validation":
|
|
||||||
validatorAction = "dirty"
|
|
||||||
case "missing-note":
|
|
||||||
f.git("rm", "docs/releases/v0.9.0.md")
|
|
||||||
f.git("commit", "-qm", "Remove note")
|
|
||||||
case "mismatched-note":
|
|
||||||
f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.1\n", 0600)
|
|
||||||
f.git("add", ".")
|
|
||||||
f.git("commit", "-qm", "Mismatch note")
|
|
||||||
case "local-tag":
|
|
||||||
f.git("tag", "v0.9.0")
|
|
||||||
case "remote-tag":
|
|
||||||
f.git("tag", "v0.9.0")
|
|
||||||
f.git("push", f.remote, "refs/tags/v0.9.0")
|
|
||||||
f.git("tag", "-d", "v0.9.0")
|
|
||||||
case "remote-tag-after-validation":
|
|
||||||
validatorAction = "remote-tag"
|
|
||||||
case "remote-query-failure":
|
|
||||||
if err := os.Rename(f.remote, f.remote+".unavailable"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
case "failed-validation":
|
|
||||||
validatorAction = "fail"
|
|
||||||
case "non-fast-forward":
|
|
||||||
f.git("checkout", "-qb", "other", f.base)
|
|
||||||
f.git("commit", "--allow-empty", "-qm", "Divergent source")
|
|
||||||
f.git("push", f.remote, "HEAD:refs/heads/main")
|
|
||||||
f.git("checkout", "main")
|
|
||||||
}
|
|
||||||
out, err := f.run(version, "", validatorAction)
|
|
||||||
success := scenario == "success" || scenario == "main-already-current" ||
|
|
||||||
scenario == "ignored-file" || scenario == "unrelated-tag"
|
|
||||||
if success != (err == nil) {
|
|
||||||
t.Fatalf("unexpected publication result: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
wantChecks := 1
|
|
||||||
switch scenario {
|
|
||||||
case "malformed-version", "wrong-branch", "wrong-destination",
|
|
||||||
"multiple-destinations", "dirty-worktree", "missing-note",
|
|
||||||
"mismatched-note", "local-tag", "remote-tag", "remote-query-failure",
|
|
||||||
"non-fast-forward":
|
|
||||||
wantChecks = 0
|
|
||||||
}
|
|
||||||
if got := f.validations(); got != wantChecks {
|
|
||||||
t.Fatalf("validator ran %d times, want %d", got, wantChecks)
|
|
||||||
}
|
|
||||||
if success {
|
|
||||||
if got := f.remoteRef("refs/heads/main"); got != f.candidate {
|
|
||||||
t.Fatalf("published main %s, want %s", got, f.candidate)
|
|
||||||
}
|
|
||||||
if got := f.remoteRef("refs/tags/v0.9.0^{}"); got != f.candidate {
|
|
||||||
t.Fatalf("tag points to %s, want %s", got, f.candidate)
|
|
||||||
}
|
|
||||||
if f.remoteRef("refs/tags/v0.8.0") != "" {
|
|
||||||
t.Fatal("unrelated local tag was published")
|
|
||||||
}
|
|
||||||
tagData := f.git("--git-dir="+f.remote, "cat-file", "-p", "refs/tags/v0.9.0")
|
|
||||||
_, message, ok := strings.Cut(tagData, "\n\n")
|
|
||||||
if !ok || strings.TrimSpace(message) != strings.TrimSpace(f.git("show", f.candidate+":docs/releases/v0.9.0.md")) {
|
|
||||||
t.Fatal("annotated tag does not contain the checked-in note")
|
|
||||||
}
|
|
||||||
} else if scenario != "remote-tag" && scenario != "remote-tag-after-validation" &&
|
|
||||||
f.remoteRef("refs/tags/v0.9.0") != "" {
|
|
||||||
t.Fatal("failed guard published a tag")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReleasePublicationRecovery(t *testing.T) {
|
|
||||||
for _, scenario := range []string{
|
|
||||||
"main-lost", "main-reject", "main-unknown",
|
|
||||||
"tag-lost", "tag-reject", "tag-conflict", "tag-unknown",
|
|
||||||
} {
|
|
||||||
t.Run(scenario, func(t *testing.T) {
|
|
||||||
f := newReleaseFixture(t)
|
|
||||||
out, err := f.run("v0.9.0", scenario, "")
|
|
||||||
success := scenario == "main-lost" || scenario == "tag-lost"
|
|
||||||
if success != (err == nil) {
|
|
||||||
t.Fatalf("unexpected publication result: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
if got := f.validations(); got != 1 {
|
|
||||||
t.Fatalf("validator ran %d times", got)
|
|
||||||
}
|
|
||||||
remoteMain := f.remoteRef("refs/heads/main")
|
|
||||||
remoteTag := f.remoteRef("refs/tags/v0.9.0")
|
|
||||||
localTag, localTagErr := f.gitResult("rev-parse", "--verify", "refs/tags/v0.9.0")
|
|
||||||
if localTagErr != nil {
|
|
||||||
localTag = ""
|
|
||||||
}
|
|
||||||
switch scenario {
|
|
||||||
case "main-lost", "tag-lost":
|
|
||||||
if remoteMain != f.candidate || remoteTag == "" || remoteTag != localTag {
|
|
||||||
t.Fatal("confirmed publication did not preserve exact refs")
|
|
||||||
}
|
|
||||||
case "main-reject", "main-unknown":
|
|
||||||
if remoteMain != f.base || remoteTag != "" || localTag != "" {
|
|
||||||
t.Fatal("failed main push created publication state")
|
|
||||||
}
|
|
||||||
case "tag-reject", "tag-unknown":
|
|
||||||
if remoteMain != f.candidate || remoteTag != "" || localTag == "" {
|
|
||||||
t.Fatal("failed tag push did not retain branch-only state")
|
|
||||||
}
|
|
||||||
case "tag-conflict":
|
|
||||||
if remoteMain != f.candidate || remoteTag != f.base || localTag == "" {
|
|
||||||
t.Fatal("conflicting remote tag was overwritten")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReleasePublicationUsesValidatedDestination(t *testing.T) {
|
|
||||||
for _, behavior := range []string{"success", "main-lost", "tag-lost"} {
|
|
||||||
t.Run(behavior, func(t *testing.T) {
|
|
||||||
f := newReleaseFixture(t)
|
|
||||||
other := filepath.Join(t.TempDir(), "other.git")
|
|
||||||
f.git("init", "-q", "--bare", other)
|
|
||||||
f.git("remote", "set-url", "--push", "origin", f.remote)
|
|
||||||
f.git("config", "url."+other+".pushInsteadOf", f.remote)
|
|
||||||
// These defaults must not broaden the explicit release refspecs when
|
|
||||||
// publishing through the validated named remote.
|
|
||||||
f.git("config", "remote.origin.mirror", "true")
|
|
||||||
f.git("config", "remote.origin.push", "refs/heads/*:refs/heads/*")
|
|
||||||
f.git("branch", "unrelated", f.base)
|
|
||||||
f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base)
|
|
||||||
f.git("--git-dir="+f.remote, "update-ref", "refs/heads/keep", f.base)
|
|
||||||
|
|
||||||
out, err := f.run("v0.9.0", behavior, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("publish: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
if f.validations() != 1 || f.remoteRef("refs/heads/main") != f.candidate ||
|
|
||||||
f.remoteRef("refs/tags/v0.9.0") != f.git("rev-parse", "refs/tags/v0.9.0") {
|
|
||||||
t.Fatal("publication did not reach the validated destination")
|
|
||||||
}
|
|
||||||
if f.remoteRef("refs/heads/keep") != f.base ||
|
|
||||||
f.remoteRef("refs/heads/unrelated") != "" || f.remoteRef("refs/tags/v0.8.0") != "" {
|
|
||||||
t.Fatal("publication changed unrelated refs")
|
|
||||||
}
|
|
||||||
if refs := f.git("--git-dir="+other, "for-each-ref", "--format=%(refname)"); refs != "" {
|
|
||||||
t.Fatalf("publication reached the rewrite destination: %s", refs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
releasekit.json
Normal file
12
releasekit.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"product": "ConfigKit",
|
||||||
|
"remote": "origin",
|
||||||
|
"expected_push_url": "https://gitea.maximumdirect.net/eric/ConfigKit.git",
|
||||||
|
"validation": {
|
||||||
|
"go": {
|
||||||
|
"race": true,
|
||||||
|
"full_vet": true,
|
||||||
|
"compile_targets": ["linux/amd64", "linux/arm64"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
releasekit_profile_test.go
Normal file
57
releasekit_profile_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseKitProfile(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("releasekit.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile struct {
|
||||||
|
Product string `json:"product"`
|
||||||
|
Remote string `json:"remote"`
|
||||||
|
ExpectedPushURL string `json:"expected_push_url"`
|
||||||
|
Validation struct {
|
||||||
|
Go struct {
|
||||||
|
Race bool `json:"race"`
|
||||||
|
FullVet bool `json:"full_vet"`
|
||||||
|
CompileTargets []string `json:"compile_targets"`
|
||||||
|
} `json:"go"`
|
||||||
|
} `json:"validation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&profile); err != nil {
|
||||||
|
t.Fatalf("decode release profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.Product != "ConfigKit" {
|
||||||
|
t.Errorf("product = %q, want ConfigKit", profile.Product)
|
||||||
|
}
|
||||||
|
if profile.Remote != "origin" {
|
||||||
|
t.Errorf("remote = %q, want origin", profile.Remote)
|
||||||
|
}
|
||||||
|
wantPushURL := "https://gitea.maximumdirect.net/eric/ConfigKit.git"
|
||||||
|
if fixturePushURL := os.Getenv("CONFIGKIT_TEST_EXPECTED_PUSH_URL"); fixturePushURL != "" {
|
||||||
|
wantPushURL = fixturePushURL
|
||||||
|
}
|
||||||
|
if profile.ExpectedPushURL != wantPushURL {
|
||||||
|
t.Errorf("expected push URL = %q, want %q", profile.ExpectedPushURL, wantPushURL)
|
||||||
|
}
|
||||||
|
goValidation := profile.Validation.Go
|
||||||
|
if !goValidation.Race || !goValidation.FullVet {
|
||||||
|
t.Errorf("race and full vet must be selected: race=%t full_vet=%t", goValidation.Race, goValidation.FullVet)
|
||||||
|
}
|
||||||
|
wantTargets := []string{"linux/amd64", "linux/arm64"}
|
||||||
|
if !reflect.DeepEqual(goValidation.CompileTargets, wantTargets) {
|
||||||
|
t.Errorf("compile targets = %v, want %v", goValidation.CompileTargets, wantTargets)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
|
||||||
cd "$repository_dir"
|
|
||||||
|
|
||||||
baseline_toolchain=${CONFIGKIT_BASELINE_TOOLCHAIN:?set CONFIGKIT_BASELINE_TOOLCHAIN to a cached supported Go 1.25 patch release}
|
|
||||||
|
|
||||||
run_go() {
|
|
||||||
local toolchain=$1
|
|
||||||
shift
|
|
||||||
GOTOOLCHAIN="$toolchain" GOPROXY=off go "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off go version >/dev/null; then
|
|
||||||
printf 'release toolchain %s is unavailable; acquire it before validation\n' "$baseline_toolchain" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verbose output reports skipped cases without a separate evidence run.
|
|
||||||
run_go "$baseline_toolchain" test -mod=readonly -vet=off -race -v ./...
|
|
||||||
run_go "$baseline_toolchain" vet -mod=readonly ./...
|
|
||||||
|
|
||||||
# Only release-validated targets gate publication; macOS is best-effort.
|
|
||||||
for target in linux/amd64 linux/arm64; do
|
|
||||||
target_os=${target%/*}
|
|
||||||
target_arch=${target#*/}
|
|
||||||
GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build -mod=readonly ./...
|
|
||||||
done
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
|
||||||
cd "$repository_dir"
|
|
||||||
|
|
||||||
remote_url=https://gitea.maximumdirect.net/eric/ConfigKit.git
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
printf '%s\n' "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
check_candidate() {
|
|
||||||
local context=$1 current status
|
|
||||||
current=$(git branch --show-current) || fail "$context: cannot read the current branch"
|
|
||||||
[[ "$current" == main ]] || fail "$context: release requires the main branch"
|
|
||||||
status=$(git status --porcelain=v1 --untracked-files=all) || fail "$context: cannot inspect the worktree"
|
|
||||||
[[ -z "$status" ]] || fail "$context: release requires a clean index and worktree"
|
|
||||||
current=$(git rev-parse --verify HEAD) || fail "$context: cannot identify the candidate"
|
|
||||||
[[ "$current" == "$candidate" ]] || fail "$context: candidate changed; commit and validate it again"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_unused_version() {
|
|
||||||
if git show-ref --verify --quiet "refs/tags/$version"; then
|
|
||||||
fail "version already has a local tag"
|
|
||||||
fi
|
|
||||||
local oid result
|
|
||||||
if oid=$(read_remote_ref "refs/tags/$version"); then
|
|
||||||
fail "version already has an authoritative tag"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
((result == 2)) || fail "cannot determine authoritative tag state; publication status is unknown"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
read_remote_ref() {
|
|
||||||
local ref=$1 response result
|
|
||||||
if response=$(git ls-remote --refs --exit-code origin "$ref" 2>/dev/null); then
|
|
||||||
[[ "$response" == *$'\t'"$ref" ]] || return 3
|
|
||||||
printf '%s\n' "${response%%$'\t'*}"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
((result == 2)) && return 2
|
|
||||||
return 3
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($# != 1)); then
|
|
||||||
fail "usage: ./scripts/release.sh vMAJOR.MINOR.PATCH"
|
|
||||||
fi
|
|
||||||
version=$1
|
|
||||||
[[ "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
|
|
||||||
fail "release version must be a stable vMAJOR.MINOR.PATCH without leading zeroes"
|
|
||||||
|
|
||||||
fetch_urls=$(git remote get-url --all origin 2>/dev/null) || fail "origin is unavailable"
|
|
||||||
push_urls=$(git remote get-url --push --all origin 2>/dev/null) || fail "origin push destination is unavailable"
|
|
||||||
[[ "$fetch_urls" == "$remote_url" && "$push_urls" == "$remote_url" ]] ||
|
|
||||||
fail "origin must have exactly the authoritative fetch and push destination"
|
|
||||||
|
|
||||||
candidate=$(git rev-parse --verify HEAD) || fail "cannot identify the candidate"
|
|
||||||
check_candidate "before validation"
|
|
||||||
check_unused_version
|
|
||||||
|
|
||||||
note_path="docs/releases/$version.md"
|
|
||||||
note_file=$(mktemp)
|
|
||||||
trap 'rm -f -- "$note_file"' EXIT
|
|
||||||
git show "$candidate:$note_path" >"$note_file" 2>/dev/null ||
|
|
||||||
fail "matching release note is absent from the candidate"
|
|
||||||
IFS= read -r title <"$note_file" || [[ -n "$title" ]] ||
|
|
||||||
fail "matching release note is empty"
|
|
||||||
[[ "$title" == "# ConfigKit $version" ]] || fail "release note title does not match the version"
|
|
||||||
|
|
||||||
if remote_main=$(read_remote_ref refs/heads/main); then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
((result == 2)) && fail "authoritative main is absent"
|
|
||||||
fail "cannot read authoritative main; publication status is unknown"
|
|
||||||
fi
|
|
||||||
git fetch --no-tags origin refs/heads/main >/dev/null ||
|
|
||||||
fail "cannot fetch authoritative main"
|
|
||||||
fetched_main=$(git rev-parse --verify FETCH_HEAD) || fail "cannot inspect fetched main"
|
|
||||||
git merge-base --is-ancestor "$fetched_main" "$candidate" ||
|
|
||||||
fail "candidate cannot advance authoritative main without discarding history"
|
|
||||||
|
|
||||||
./scripts/release-check.sh || fail "release validation failed; no refs were published"
|
|
||||||
check_candidate "after validation"
|
|
||||||
check_unused_version
|
|
||||||
|
|
||||||
# Use the named remote whose resolved destinations were checked above: a literal
|
|
||||||
# URL can apply pushInsteadOf even when origin's explicit push URL does not.
|
|
||||||
# Disable mirror defaults as well as implicit tags; publish only our refspec.
|
|
||||||
if git -c remote.origin.mirror=false push --no-follow-tags origin "$candidate:refs/heads/main"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
if observed_main=$(read_remote_ref refs/heads/main); then
|
|
||||||
[[ "$observed_main" == "$candidate" ]] ||
|
|
||||||
fail "main push did not publish candidate $candidate; authoritative main differs"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
((result == 2)) && fail "main push failed for $candidate; authoritative main is absent"
|
|
||||||
fail "main push outcome is unknown for $candidate; inspect authoritative main before retrying"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
check_candidate "main at $candidate; tag not yet published"
|
|
||||||
git tag -a -F "$note_file" --cleanup=verbatim "$version" "$candidate" ||
|
|
||||||
fail "main is at $candidate; could not create local tag $version"
|
|
||||||
tag_target=$(git rev-parse --verify "refs/tags/$version^{}") ||
|
|
||||||
fail "main is at $candidate; cannot verify local tag $version"
|
|
||||||
[[ "$tag_target" == "$candidate" ]] || fail "main is at $candidate; local tag $version targets another commit"
|
|
||||||
tag_type=$(git cat-file -t "refs/tags/$version") ||
|
|
||||||
fail "main is at $candidate; cannot inspect local tag $version"
|
|
||||||
[[ "$tag_type" == tag ]] || fail "main is at $candidate; local tag $version is not annotated"
|
|
||||||
tag_oid=$(git rev-parse --verify "refs/tags/$version") ||
|
|
||||||
fail "main is at $candidate; cannot identify local tag $version"
|
|
||||||
|
|
||||||
if git -c remote.origin.mirror=false push --no-follow-tags origin "refs/tags/$version:refs/tags/$version"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
if observed_tag=$(read_remote_ref "refs/tags/$version"); then
|
|
||||||
[[ "$observed_tag" == "$tag_oid" ]] ||
|
|
||||||
fail "authoritative tag $version conflicts with the local tag at $candidate; never replace it"
|
|
||||||
else
|
|
||||||
result=$?
|
|
||||||
((result == 2)) &&
|
|
||||||
fail "tag push failed; main is at $candidate, but local tag $version is unpublished"
|
|
||||||
fail "tag $version push outcome is unknown for $candidate; retain the local tag and inspect authoritative state"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'Published %s at %s\n' "$version" "$candidate"
|
|
||||||
159
startup_bench_test.go
Normal file
159
startup_bench_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These synthetic types retain the optional-field shape of a catalog consumer
|
||||||
|
// without importing its adapter, template compiler, or runtime registries.
|
||||||
|
type startupCatalog struct {
|
||||||
|
Profiles map[string]startupProfile `yaml:"profiles"`
|
||||||
|
Prompts map[string]map[string]startupPrompt `yaml:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupProfile struct {
|
||||||
|
Backend *string `yaml:"backend"`
|
||||||
|
Endpoint *string `yaml:"endpoint"`
|
||||||
|
Model *string `yaml:"model"`
|
||||||
|
Temperature *float64 `yaml:"temperature"`
|
||||||
|
TopP *float64 `yaml:"top_p"`
|
||||||
|
MaxTokens *int `yaml:"max_tokens"`
|
||||||
|
TimeoutSeconds *int `yaml:"timeout_seconds"`
|
||||||
|
ServiceTier *string `yaml:"service_tier"`
|
||||||
|
ReasoningEffort *string `yaml:"reasoning_effort"`
|
||||||
|
APIKeyEnv *string `yaml:"api_key_env"`
|
||||||
|
APIKeyRequired *bool `yaml:"api_key_required"`
|
||||||
|
ExtraParams map[string]any `yaml:"extra_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupPrompt struct {
|
||||||
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
|
Description *string `yaml:"description"`
|
||||||
|
SessionID *string `yaml:"session_id"`
|
||||||
|
Inputs []startupInput `yaml:"inputs"`
|
||||||
|
Messages []startupMessage `yaml:"messages"`
|
||||||
|
Output *startupOutput `yaml:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupInput struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
ContentType *string `yaml:"content_type"`
|
||||||
|
Description *string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupMessage struct {
|
||||||
|
Role *string `yaml:"role"`
|
||||||
|
Content *string `yaml:"content"`
|
||||||
|
ContentFile *string `yaml:"content_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupOutput struct {
|
||||||
|
Format *string `yaml:"format"`
|
||||||
|
ValidationMode *string `yaml:"validation_mode"`
|
||||||
|
SchemaPath *string `yaml:"schema_path"`
|
||||||
|
RepairAttempts int `yaml:"repair_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupPresence struct{ explicit, null bool }
|
||||||
|
|
||||||
|
var startupResult *configkit.Result[startupCatalog]
|
||||||
|
|
||||||
|
func BenchmarkStartupConstruction(b *testing.B) {
|
||||||
|
for _, count := range []int{32, 128, 512} {
|
||||||
|
b.Run(fmt.Sprint(count), func(b *testing.B) {
|
||||||
|
profiles, prompts := make(map[string]any, count), make(map[string]any, count)
|
||||||
|
for index := range count {
|
||||||
|
profileID := fmt.Sprintf("profile-%03d", index)
|
||||||
|
promptID := fmt.Sprintf("prompt-%03d", index)
|
||||||
|
profiles[profileID] = map[string]any{"endpoint": "https://example.test/v1", "model": profileID}
|
||||||
|
prompts[promptID] = map[string]any{"1": map[string]any{
|
||||||
|
"default_profile": profileID,
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
// Mapping captures the source once outside timing, matching the
|
||||||
|
// downstream benchmark's reusable input. Each load still owns its result.
|
||||||
|
main := configkit.Input{Source: configkit.Mapping("startup catalog", map[string]any{
|
||||||
|
"profiles": profiles, "prompts": prompts,
|
||||||
|
})}
|
||||||
|
var captured map[string]map[string]startupPresence
|
||||||
|
validated := false
|
||||||
|
options := configkit.LayeredOptions[startupCatalog]{Definition: configkit.Definition[startupCatalog]{
|
||||||
|
// Include a simple path-based classifier, as catalog consumers use
|
||||||
|
// this callback even when the representative input has no credentials.
|
||||||
|
Secret: func(path configkit.Path) bool {
|
||||||
|
return strings.HasSuffix(path.String(), "/extra_params/token")
|
||||||
|
},
|
||||||
|
Resolve: func(catalog *startupCatalog, resolution *configkit.Resolution) error {
|
||||||
|
captured = make(map[string]map[string]startupPresence, len(catalog.Profiles))
|
||||||
|
for id, profile := range catalog.Profiles {
|
||||||
|
fields := make(map[string]startupPresence, 12)
|
||||||
|
for _, field := range []string{
|
||||||
|
"backend", "endpoint", "model", "temperature", "top_p", "max_tokens",
|
||||||
|
"timeout_seconds", "service_tier", "reasoning_effort", "api_key_env",
|
||||||
|
"api_key_required", "extra_params",
|
||||||
|
} {
|
||||||
|
info, exists := resolution.InputInfo(configkit.Keys("profiles", id, field))
|
||||||
|
fields[field] = startupPresence{explicit: exists && info.Explicit, null: exists && info.Null}
|
||||||
|
}
|
||||||
|
captured[id] = fields
|
||||||
|
// The representative endpoints are already canonical, so this
|
||||||
|
// callback, like the consumer's, records no normalization change.
|
||||||
|
if _, err := url.Parse(*profile.Endpoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(catalog *startupCatalog, validation *configkit.Validation) {
|
||||||
|
validated = true
|
||||||
|
ids := make([]string, 0, len(catalog.Profiles))
|
||||||
|
for id := range catalog.Profiles {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
for _, id := range ids {
|
||||||
|
profile := catalog.Profiles[id]
|
||||||
|
if profile.Model == nil || strings.TrimSpace(*profile.Model) == "" {
|
||||||
|
validation.Error(configkit.Keys("profiles", id, "model"), "model is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// Check the measured workload before timing, including numeric mapping
|
||||||
|
// keys, sequence paths, callback presence capture, and published metadata.
|
||||||
|
result, err := configkit.LoadLayered(main, options)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if !validated || len(result.Config.Profiles) != count || len(result.Config.Prompts) != count || len(captured) != count {
|
||||||
|
b.Fatal("startup fixture did not exercise the expected lifecycle")
|
||||||
|
}
|
||||||
|
fields := captured["profile-000"]
|
||||||
|
if !fields["endpoint"].explicit || !fields["model"].explicit || fields["temperature"].explicit {
|
||||||
|
b.Fatal("startup fixture presence capture disagrees with input")
|
||||||
|
}
|
||||||
|
path := configkit.Keys("prompts", "prompt-000", "1", "messages").Index(0).Key("content")
|
||||||
|
if info, exists := result.Metadata().Lookup(path); !exists || !info.Explicit || info.Null {
|
||||||
|
b.Fatal("startup fixture message provenance is missing")
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
result, err := configkit.LoadLayered(main, options)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
startupResult = result
|
||||||
|
}
|
||||||
|
b.ReportMetric(float64(count*2), "definitions/op")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user