50 Commits

Author SHA1 Message Date
1a885934c2 Prepare release notes for v0.6.0 2026-10-09 04:29:32 +00:00
5917cebf50 Retire completed roadmaps for release preparation 2026-10-09 04:28:16 +00:00
8e91a2b16b Upgrade ReleaseKit to v0.3.0 2026-10-09 04:26:30 +00:00
08402d098b Adopt updated release toolchain policy 2026-10-09 04:23:15 +00:00
53b79528d9 Keep reference algorithms in tests 2026-10-09 04:14:45 +00:00
f849e06b77 Correct the unindexed sensitivity fixture 2026-10-09 04:11:50 +00:00
b37948cfea Reuse parsed sensitivity paths 2026-10-09 04:10:41 +00:00
9323f89ad1 Record performance implementation review findings 2026-10-09 04:07:46 +00:00
6c62226838 Record performance audit completion 2026-10-09 04:01:14 +00:00
799ebab5a6 Index resolution and origin membership 2026-10-09 03:59:16 +00:00
c8546ae599 Index inherited step membership 2026-10-09 03:56:29 +00:00
d32c94eca3 Reuse frozen inspection mapping lookups 2026-10-09 03:54:14 +00:00
344f4f73d7 Index inheritance history lookups 2026-10-09 03:52:07 +00:00
5fedc11c8f Prepare coalesced resolution replay 2026-10-09 03:49:43 +00:00
5b8b7a2415 Index CLI override validation 2026-10-09 03:45:43 +00:00
e591e97f22 Integrate indexed sensitivity propagation 2026-10-09 03:42:29 +00:00
71e142afb4 Add indexed secret dependency closure 2026-10-09 03:41:27 +00:00
ca1ba04676 Collect sensitivity directly from document trees 2026-10-09 03:39:12 +00:00
528495c75a Index resolution metadata updates 2026-10-09 03:36:29 +00:00
aa3b4a5881 Index resolution coverage and document reads 2026-10-09 03:32:30 +00:00
db2d8ed217 Add private canonical path prefix index 2026-10-09 03:30:01 +00:00
d34a98d34b Precompute canonical paths before sorting 2026-10-09 03:27:42 +00:00
9ac65ccc08 Compare canonical paths by token spelling 2026-10-09 03:23:08 +00:00
edc6660654 Detect inheritance directives without paths 2026-10-09 03:20:24 +00:00
a2084df11a Make explicit metadata queries lightweight 2026-10-09 03:18:50 +00:00
b59f002314 Record performance audit baseline evidence 2026-10-09 03:15:39 +00:00
52793301ba Plan the performance improvements 2026-10-09 03:13:22 +00:00
0c77deed05 Record performance audit and optimization candidates 2026-10-09 02:09:25 +00:00
48619de024 Verify startup loading improvements 2026-10-09 01:19:13 +00:00
c546124029 Combine final metadata traversal 2026-10-09 00:52:55 +00:00
673faea097 Reduce path formatting allocations 2026-10-09 00:34:21 +00:00
91ae4fa7ef Measure startup configuration loading 2026-10-09 00:14:19 +00:00
f1722274c0 Add an ordered roadmap for startup loading performance 2026-10-08 23:58:35 +00:00
d53021ea2c Request more efficient configuration loading for PromptKit 2026-10-08 23:50:02 +00:00
a22aaa5dfa Mark ReleaseKit adoption complete 2026-10-08 17:08:53 +00:00
b1ecd9a078 Clarify release command exit statuses 2026-10-08 17:08:35 +00:00
84b59c690d Correct release recovery endpoint checks 2026-10-08 17:08:00 +00:00
ac90b9835b Record ReleaseKit adoption review findings 2026-10-08 17:01:58 +00:00
f887fac809 Complete ReleaseKit endpoint and recovery validation 2026-10-08 16:55:10 +00:00
c03457c814 Verify ReleaseKit against a disposable remote 2026-10-08 16:51:09 +00:00
e9b6b63e5e Retire duplicated release scripts and tests 2026-10-08 16:46:28 +00:00
1dc50d8cdf Replace the release procedure with ReleaseKit 2026-10-08 16:42:56 +00:00
0e92085002 Align release policy with ReleaseKit 2026-10-08 16:37:10 +00:00
f96f2b8295 Add the ReleaseKit source profile 2026-10-08 16:35:58 +00:00
1917dcf921 Declare ReleaseKit as a Go tool dependency 2026-10-08 16:34:58 +00:00
0ac4731903 Separate Go minimum from release toolchain 2026-10-08 16:23:25 +00:00
c42930c6d5 Plan ReleaseKit migration stages 2026-10-08 13:48:36 +00:00
2d3ea588f1 Resolve ReleaseKit adoption decisions 2026-10-08 13:40:50 +00:00
5aafd943b4 Plan Go toolchain updates for releases 2026-10-08 13:33:43 +00:00
c0605fab44 Plan ReleaseKit adoption 2026-10-08 13:12:18 +00:00
52 changed files with 2756 additions and 1070 deletions

View File

@@ -66,7 +66,7 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
t.Fatal(err)
}
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.
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
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)
}
})
}
}

View File

@@ -64,8 +64,9 @@ git show cb5b435:docs/roadmap/remediation.md
```
Release-candidate, publication, and verification work follows the operational
[release procedure](release.md). Its single publication command validates the
committed candidate and publishes only the intended refs.
[release procedure](release.md). Its module-selected ReleaseKit `publish`
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
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),
and release operations belong to the [release procedure](release.md).
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
dependencies explicitly before entering an offline development or validation
environment:
The completed ReleaseKit adoption, startup performance request and experiments,
benchmark evidence, performance audit, implementation plan, and remediation
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
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
check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes.
The [publication command](release.md#4-publish-the-committed-candidate) invokes
the release checker once. It performs one minimum-toolchain offline race-suite
run, one full vet pass, and Linux cross-builds using readonly module mode.
It already covers examples, links, and package boundaries; do not additionally
run the everyday checker just to repeat those checks. CI never gates a release.
The [release procedure](release.md#4-publish-the-committed-candidate) performs
its selected validation through one `publish` invocation. The everyday checker
remains a development gate and is not a separate release prerequisite. CI never
gates a release.
## 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` |
| 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'` |
| Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` |
| Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` |
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
## Fuzz validation

View File

@@ -2,11 +2,12 @@
## 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/architecture.md`, `docs/policy/testing.md`, and
`docs/policy/release.md`; root `AGENTS.md`; and the Policy Reading Guide in
`docs/development.md`, adapted for this repository.
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
Policy Reading Guide in `docs/development.md`, adapted from
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
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) |
[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/e833faddf120c789a9b45348edaf75720de16348).
## go-yaml v4
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.

View File

@@ -1,10 +1,10 @@
# 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
@@ -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
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
summaries belong in `docs/releases/`. The procedure must agree with the adopted
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.
The procedure must choose and document the supported approach.
ConfigKit uses sequential explicit pushes: first the captured candidate to
authoritative `main`, then the annotated tag. Its
[release procedure](../release.md) names `origin` and checks that its fetch and
push destinations resolve to the authoritative URL.
ConfigKit uses sequential publication: first push the committed candidate to
authoritative `main`, then invoke ReleaseKit once to validate that candidate and
publish its annotated tag. The [release procedure](../release.md) names
`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
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
to that source and identify the same version.
ConfigKit currently declares no additional deliverables. Its source-tag push
is the completion point.
ConfigKit currently declares no additional deliverables. Confirmed publication
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
@@ -96,7 +101,8 @@ requires a deliberate update to the policy and procedure where needed.
For releases at or after `v1.0.0`:
- 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.
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
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
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
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
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
before publication repeats the same checks.
ConfigKit uses `./scripts/release.sh vMAJOR.MINOR.PATCH`. The separate
`scripts/release-check.sh` remains an optional diagnostic command; the
publication command invokes it once and does not use a persistent candidate
record.
ConfigKit uses the module-selected ReleaseKit CLI's `publish
vMAJOR.MINOR.PATCH` command. Its `check` command is optional for diagnosis; it
is not a required preliminary pass to publication.
Run the complete deterministic, offline default test suite once for the final
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
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
detector and test-time vet disabled. This protects library behavior, ownership,
and concurrent use; the suite already 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 builds
cover supported configurations distinct from native race compilation. macOS
and newer-toolchain checks are optional.
Prepare dependencies and build the tool before offline validation; the
publication command is the required validation run, with no separate successful
test or CI run required before it.
The selected profile runs the complete deterministic, offline default suite
once with the race detector and test-time vet disabled. The suite already
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
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
check must not silently become a permanent release requirement.
The selected Go commands use readonly module mode with `GOPROXY=off`; acquire
the baseline toolchain and dependencies before validation. Formatting and
whitespace remain ordinary development checks in `scripts/check.sh`.
ReleaseKit runs its selected Go commands with readonly module mode and
`GOPROXY=off`; acquire dependencies and build the tool before candidate
validation. Formatting and whitespace remain ordinary development checks in
`scripts/check.sh`.
Review module tidiness when dependencies change, not as a release gate.
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;
- the worktree and index are clean on the primary release branch;
- 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
discarding remote history, and is published before the tag; and
- the tag identifies the validated candidate and only the intended refs are
published.
ConfigKit also checks that `origin` has exactly the authoritative fetch and
push destination, including configured push URLs. 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.
ReleaseKit checks that `origin`'s effective push URL exactly matches the
authoritative URL before candidate validation. This does not assert the
configured fetch URL or require the release-note H1 to be the first line; the
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.
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
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
identify prerequisites, the primary branch and authoritative remote, version
and note preparation, one publication command with local checks and guards,
source-only deliverables, the successful-tag-push completion point, and
partial or unknown-state recovery.
identify prerequisites, the selected release toolchain, the primary branch and
authoritative remote, version and note preparation, one publication command with
local checks and guards, source-only deliverables, the successful-tag-push
completion point, and partial or unknown-state recovery.
Use copyable commands where practical. Keep optional diagnostics and consumer
verification clearly optional. The procedure must not turn preparation work,

View File

@@ -1,7 +1,7 @@
# ConfigKit Release Procedure
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,
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`
- Primary branch: `main`
- Authoritative remote: `origin`, with fetch and push destination
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
- Authoritative remote: `origin`
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
- 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
- Best-effort targets: macOS amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support)
The checker runs one offline race suite, one full vet pass, and CGO-disabled
builds for both Linux targets. It reports skipped filesystem cases. macOS checks
and newer-toolchain tests are optional. CI is never a readiness or completion
gate; do not wait for or poll it.
Release validation runs one offline race-enabled Go suite, one full vet pass,
and CGO-disabled package compilation for both Linux targets. macOS and CI are
not release gates; do not wait for or poll CI.
## 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,
and a native compiler for race tests. Configure private-host access through the
normal credential manager. Keep credentials and environment dumps outside the
repository and command transcript.
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
tests. Choose the release type before selecting Go. For a minor or major release,
select the newest stable Go series available in the development environment.
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
git remote get-url origin
git ls-remote origin HEAD
export GOTOOLCHAIN=local
go version
go env GOVERSION
command -v go
```
The fetch URL and all configured push destinations must resolve to the
authoritative URL above. The publication command checks this again. Acquire the
selected toolchain and dependencies explicitly before offline validation:
Only when a minor or major release advances the consumer minimum to a newer Go
series, derive that series' initial stable release from the selected executable:
```sh
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
selected_go=$(GOTOOLCHAIN=local go env GOVERSION)
selected_series=${selected_go#go}
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
survey is required. Dependencies must be cached; the checker sets `GOPROXY=off`.
Publication requires user authorization. Standing authorization for local
commits does not authorize a push or release.
If a dependency or identified correctness fix requires a higher patch minimum,
use that required version instead and document the reason. When the series is
unchanged, leave the minimum unchanged unless a minor or major 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.
Review exported Go signatures and ownership, YAML and scalar semantics,
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.
```sh
go mod download
```
When dependencies change, review parser behavior and migration impact as part
of that change. Module tidiness belongs to dependency-change review, not the
release checker. Do not repeat a dependency-version survey for every release.
Build the tool selected by `go.mod` into temporary storage outside the checkout.
Ensure `$TMPDIR` names a system temporary location, not the repository. These
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
must be `# ConfigKit <version>`. Give a meaningful summary and include
compatibility impact or upgrade instructions when applicable. Link to current
contracts for exact behavior. Review the note and affected canonical documents
as ordinary preparation, then commit the complete candidate.
The module-selected executable is ReleaseKit v0.3.0. Use the built executable
for publication to preserve its exit status and publication-state diagnostics.
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
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
[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
the URLs. The future tag need not resolve remotely yet; this review adds no
network gate. ConfigKit uses checked-in notes without hosted release objects.
If a hosted page is used later, reuse the note and preserve its footer. Do not
rewrite historical notes or add downloadable license assets.
Replace `VERSION` with the full tag and confirm the three target files exist in
the candidate. Retire completed roadmaps, preserve unfinished and deferred
work, and repair affected links. Review the complete candidate for unintended
files and credentials, then commit it on `main`. These are ordinary preparation
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
Before publication, review compatibility, release-note accuracy, and the file
list for unintended content or credentials. Retire completed roadmaps as part of
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:
With the authorized candidate already on authoritative `main`, invoke the
module-selected native executable exactly once:
```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
and on the authoritative remote, and a matching note in the captured commit.
Ignored local files do not block it. It checks remote ancestry, runs
[`release-check.sh`](../scripts/release-check.sh) once against that commit,
rechecks candidate and version state, pushes only that commit to `main`, then
creates and pushes one annotated tag. The tag message is the checked-in note.
It never commits or repairs the checkout.
A successful intended tag push completes publication. The command reports the
version and commit immediately. No CI, module retrieval, remote read-back, or
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:
Replace the placeholder with the selected version. ReleaseKit checks the
effective push URL against `expected_push_url`, verifies the candidate on
`main`, and checks the release-note H1 for the product and version. It then
runs the selected race-enabled standard suite once with test-time vet disabled,
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
arm64. It publishes only the annotated tag for that candidate. Confirmed
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.
The optional `check`
command is available for diagnosis but is not a separate required pass:
```sh
release_version=v0.5.0
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
"$releasekit_bin" check vMAJOR.MINOR.PATCH
```
A query failure is not proof that the tag is absent. Compare the remote tag
object ID with the local annotated tag object ID; its peeled commit alone is
insufficient to establish identical publication. A conflicting published tag
must not be moved or replaced. If remote main advanced but the tag was not
published, report that partial progress. Only after confirming the local tag
was never published or used by another channel may a maintainer remove it:
Status 0 confirms publication, status 1 reports a known publication failure or
conflict, status 2 reports usage, profile, or version errors, and status 3 means
the push outcome remains unknown. Preserve both the native exit status and its
diagnostics.
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
git tag -d "$release_version"
"$releasekit_bin" publish "$release_version"
```
A subsequent invocation revalidates the candidate; a correctly published tag
needs no rerun. Do not roll back main or automatically delete a tag. Transport
or authentication failure alone does not require a new version. A defect in
published source requires a corrected commit, a new semantically appropriate
version and note, and the release procedure again.
A validation failure after the candidate reached `main` leaves an untagged
candidate there. Correct it with a new commit rather than rewriting or rolling
back `main`. A conflicting published tag must never be moved or reused. A
defect in published source requires a corrected commit, a new semantically
appropriate version, and a matching release note.
## 6. Optional consumer verification
Only 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
are optional and non-gating. Do not poll CI or wait for module propagation.
Private-host credentials must already be configured. For direct-origin
verification, set the version to the release just published (replace the sample
below), then use a temporary consumer module and check the exact module path
and version reported by `go list -m`:
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 are
optional and non-gating. Do not poll CI or wait for module propagation. For
direct-origin verification, use the exact published version in a temporary
consumer module:
```sh
release_version=v0.5.0
release_version=vMAJOR.MINOR.PATCH
consumer_dir=$(mktemp -d)
(
cd "$consumer_dir"

63
docs/releases/v0.6.0.md Normal file
View 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)

View File

@@ -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
[configuration contract](../config.md#sibling-inheritance). It is distinct from
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
View File

@@ -1,5 +1,13 @@
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 (
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
View File

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

View File

@@ -85,15 +85,28 @@ func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
for i := range canonical {
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
}
func compareDiagnostic(left, right Diagnostic) int {
func compareDiagnosticWithPaths(left, right Diagnostic, leftPath, rightPath string) int {
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order
}
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
if order := cmp.Compare(leftPath, rightPath); order != 0 {
return order
}
if order := cmp.Compare(left.Code, right.Code); order != 0 {

View File

@@ -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) {
t.Parallel()

View File

@@ -24,12 +24,23 @@ type documentDelta struct {
func compareConfigurationValues(before, after *document.Node) []documentDelta {
var deltas []documentDelta
compareConfigurationValue(before, after, Path{}, &deltas)
slices.SortFunc(deltas, func(left, right documentDelta) int {
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
type orderedDelta struct {
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 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
}

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

View File

@@ -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) {
return documentAtPathWithLookup(root, path, nil)
}
func documentAtPathWithLookup(root *document.Node, path Path, lookup *documentLookup) (*document.Node, bool) {
if root == nil || !path.valid() {
return nil, false
}
@@ -41,17 +45,21 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
if segment.kind == pathSegmentIndex {
return nil, false
}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
found = true
break
entryIndex := -1
if lookup != nil {
entryIndex = lookup.find(current, segment.key)
} else {
for index, entry := range current.Mapping {
if entry.Key == segment.key {
entryIndex = index
break
}
}
}
if !found {
if entryIndex < 0 || entryIndex >= len(current.Mapping) {
return nil, false
}
current = current.Mapping[entryIndex].Value
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {

View File

@@ -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) {
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 {
explanation.Origin = cloneOrigin(info.Origin)
}
@@ -53,7 +53,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
}
replayIndex := new(mappingIndex)
for _, application := range r.applications {
operationPath, relevant := applicationOperationPath(application, path)
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
@@ -69,7 +69,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: after,
Origin: historyApplicationOrigin(application, operationPath),
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
})
}
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
// explicitly, including canonical scalar conversions, before domain changes.
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPath(r.input, path)
decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
if !configurationValuesEqual(replayed, decoded) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(r.input, path, r.secrets),
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
Origin: origin,
Reasons: []string{"applied retained defaults and canonical typed decoding"},
})
}
working = document.Clone(r.input)
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
for _, snapshot := range r.resolutionReplay {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot
if relevant {

View File

@@ -10,6 +10,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"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)
}
}

View File

@@ -65,7 +65,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
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 {
return Path{}, false
}
@@ -80,7 +80,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
}
for length := 1; length <= len(query.segments); length++ {
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
node, present := documentAtPath(application.value, prefix)
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
if !present {
return Path{}, false
}
@@ -91,7 +91,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
return Path{}, false
}
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
func historyApplicationOrigin(application historyApplication, operationPath Path, lookup *documentLookup) Origin {
if application.origin.Layer != "" {
return cloneOrigin(application.origin)
}
@@ -100,7 +100,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
if !ok {
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)}
}
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.
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
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 {
merged := false
for i := range groups {
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
continue
pointer := snapshot.change.Path.String()
groupIndex := -1
coverage.visitAncestors(pointer, func(id int) {
if groupIndex < 0 || id < groupIndex {
groupIndex = id
}
})
if groupIndex >= 0 {
group := &groups[groupIndex]
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 {
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 {
groups[i].change.Origin.Operation = ChangeNormalization
group.change.Origin.Operation = ChangeNormalization
}
merged = true
break
continue
}
if !merged {
snapshot.change = cloneChange(snapshot.change)
groups = append(groups, snapshot)
snapshot.change = cloneChange(snapshot.change)
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
}

View File

@@ -1,6 +1,57 @@
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
// per relationship and projected onto a requested destination path only when

View File

@@ -2,6 +2,7 @@ package engine
import (
"bytes"
"reflect"
"strings"
"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) {
inspection, _ := inheritanceInspectionForTest(t,
parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n"))
@@ -324,6 +370,7 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
replaceHistoryPath(&final, path, replacement)
inspection.final = final
inspection.resolution = []resolutionSnapshot{snapshot}
inspection.resolutionReplay = coalescedResolutionSnapshots(inspection.resolution)
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}
history := inspection.Metadata().History(path)

View File

@@ -3,14 +3,28 @@ package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func containsInheritanceDirective(root *document.Node) bool {
found := false
walkDocument(root, Path{}, func(path Path, _ *document.Node) {
if len(path.segments) != 0 && path.segments[len(path.segments)-1].kind == pathSegmentKey &&
path.segments[len(path.segments)-1].key == inheritanceKey {
found = true
var visit func(*document.Node) bool
visit = func(node *document.Node) bool {
if node == nil {
return false
}
})
return found
switch node.Kind {
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 {

View File

@@ -2,6 +2,7 @@ package engine
import (
"slices"
"strconv"
"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
// traces remain compact when one parent supplies many descendant values.
func projectInheritanceSteps(origin Origin) []InheritanceStep {
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 {
var reverse []*inheritanceTrace
for link := contributor.trace; link != nil; link = link.previous {
@@ -91,17 +126,13 @@ func projectInheritanceSteps(origin Origin) []InheritanceStep {
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)
key := inheritanceStepKeyForPaths(current, next, transfer.reference, transfer.locations)
if _, found := seen[key]; !found {
seen[key] = struct{}{}
steps = append(steps, InheritanceStep{
From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
Locations: cloneLocations(transfer.locations),
})
}
current = next
}

View File

@@ -2,6 +2,8 @@ package engine
import (
"io/fs"
"reflect"
"slices"
"testing"
"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) {
first := parseCompositionDocument(t, "source-1", `first:
enabled: false

View File

@@ -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) {
const count = 2048
entries := make([]document.Entry, count)

View File

@@ -84,7 +84,9 @@ type Inspection struct {
input *document.Node
inputOrigins map[string]Origin
final *document.Node
immutableLookup *documentLookup
resolution []resolutionSnapshot
resolutionReplay []resolutionSnapshot
applications []historyApplication
inheritance []inheritanceOperation
secrets map[string]bool

View File

@@ -157,15 +157,12 @@ func originRepresentedByChange(path Path, origin Origin, change Change) bool {
// A subtree replacement already records its descendants' contributions.
// Their coordinates differ from the container's, but their sources must be
// 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 {
found := false
for _, recorded := range change.Origin.Locations {
if location.SourceID == recorded.SourceID {
found = true
break
}
}
if !found {
if _, found := recordedSources[location.SourceID]; !found {
return false
}
}
@@ -189,15 +186,12 @@ func originContainedBy(candidate, container Origin) bool {
if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
return false
}
availableLocations := make(map[Location]struct{}, len(container.Locations))
for _, location := range container.Locations {
availableLocations[location] = struct{}{}
}
for _, location := range candidate.Locations {
found := false
for _, available := range container.Locations {
if location == available {
found = true
break
}
}
if !found {
if _, found := availableLocations[location]; !found {
return false
}
}
@@ -254,9 +248,10 @@ func cloneMetadata(metadata Metadata) Metadata {
return Metadata{}
}
cloned := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)),
inheritance: append([]inheritanceOperation(nil), metadata.inheritance...),
fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)),
inheritance: append([]inheritanceOperation(nil), metadata.inheritance...),
inheritanceIndex: metadata.inheritanceIndex,
}
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
for pointer, path := range metadata.effectivePaths {
@@ -280,8 +275,23 @@ func cloneMetadata(metadata Metadata) Metadata {
}
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
for _, snapshot := range resolved.snapshots {
pointer := snapshot.change.Path.String()
finalPaths := make(map[string]*document.Node)
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)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
@@ -290,52 +300,40 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
appendResolutionDescendantHistory(metadata, resolved)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
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)
appendResolutionDescendantHistory(metadata, resolved.snapshots, snapshotPointers, finalPaths, &deltaCoverage, snapshotIDs)
applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
refreshResolutionFields(metadata.fields, finalPaths, resolved.snapshots, snapshotCoverage)
}
func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionResult) {
finalPaths := make(map[string]*document.Node)
indexDocument(resolved.final, Path{}, finalPaths)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
func appendResolutionDescendantHistory(metadata *Metadata, snapshots []resolutionSnapshot, snapshotPointers []string, finalPaths map[string]*document.Node, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
allPointers := make(map[string]struct{}, len(metadata.history)+len(finalPaths))
for pointer := range metadata.history {
if _, err := ParsePath(pointer); err == nil {
allPointers[pointer] = struct{}{}
}
}
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
}
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)
for _, pointer := range pointers {
change := cloneChange(record.change)
change := cloneChange(snapshots[snapshotID].change)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
}
@@ -344,20 +342,33 @@ func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionRe
}
}
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot {
var covering *resolutionSnapshot
for index := range snapshots {
snapshot := &snapshots[index]
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) {
covering = snapshot
func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
buckets := make([][]string, len(snapshotIDs))
for pointer := range fields {
if _, err := ParsePath(pointer); err != nil {
continue
}
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) {
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) {
for pointer := range fields {
if _, ok := finalPaths[pointer]; !ok {
delete(fields, pointer)
@@ -370,10 +381,8 @@ func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node,
continue
}
origin := Origin{Layer: LayerResolution}
if path, err := ParsePath(pointer); err == nil {
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil {
origin = cloneOrigin(covering.change.Origin)
}
if snapshotID, ok := snapshotCoverage.deepestCoveringPointerID(pointer); ok {
origin = cloneOrigin(snapshots[snapshotID].change.Origin)
}
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
}

View File

@@ -98,6 +98,11 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&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{
metadata: metadata,
sources: cloneSourceInfos(accumulator.sources),
@@ -107,7 +112,9 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
input: decoded.final,
inputOrigins: cloneOriginIndex(decoded.origins),
final: final,
immutableLookup: buildDocumentLookup(retainedTrees...),
resolution: resolutionSnapshots,
resolutionReplay: coalescedResolutionSnapshots(resolutionSnapshots),
applications: applications,
inheritance: inheritanceOperations(resolved.inheritance),
secrets: secrets,

View File

@@ -9,20 +9,18 @@ import (
// Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths.
type Metadata struct {
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
inheritance []inheritanceOperation
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
inheritance []inheritanceOperation
inheritanceIndex *inheritanceHistoryIndex
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
info, ok := m.effectiveField(path)
if !ok {
return FieldInfo{}, false
}
@@ -34,10 +32,23 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
// IsExplicit reports whether a surviving input contribution, including one
// inherited from a sibling, explicitly defined path.
func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path)
info, ok := m.effectiveField(path)
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
// Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false.
@@ -47,7 +58,11 @@ func (m Metadata) History(path Path) []Change {
return nil
}
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
@@ -79,11 +94,14 @@ func (m Metadata) HistoricalPaths() []Path {
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
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
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if !pathTokenSpellingEqual(segment, actual) {
return false
}
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
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) {
m.effectivePaths = effectivePathIndex(final)
m.effectivePaths = make(map[string]Path)
m.historicalPaths = make(map[string][]Path)
add := func(path Path, _ *document.Node) {
pointer := path.String()
addHistorical := func(path Path, pointer string) {
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
@@ -120,7 +137,14 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
}
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 {
walkDocument(tree, Path{}, add)
}
@@ -142,6 +166,7 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
walkDocument(operation.effective, operation.transfer.to, add)
}
}
m.inheritanceIndex = newInheritanceHistoryIndex(m.inheritance)
}
// effectivePathIndex records document-derived paths with their contextual segment

View File

@@ -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) {
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_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) {
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 {

View File

@@ -131,9 +131,24 @@ func previousOrigins(origin Origin) []Origin {
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
var displaced []displacedDefinition
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
return sortDisplacedDefinitions(displaced)
}
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
}

View File

@@ -117,3 +117,26 @@ func TestOwnedOverlayKeepsCompositeContributorsAndAcquiredInputs(t *testing.T) {
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])
}
}
}

View File

@@ -50,12 +50,16 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, 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
usedNodes := 0
for index, override := range overrides {
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
diagnostics = append(diagnostics, *issue)
continue
@@ -70,7 +74,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
continue
}
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{
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration override paths overlap", Primary: location,
@@ -92,7 +96,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
})
continue
}
pathAccepted[index] = true
acceptedPaths.insert(pointer, index)
builder := valueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
@@ -150,7 +154,7 @@ func validOverrideLabel(label string) bool {
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 {
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 {
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 {
continue
}
@@ -178,15 +182,6 @@ func validateOverridePath(path Path, root *document.Node) *Diagnostic {
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 {
for _, segment := range path.segments {
if segment.key == directiveKey {

View File

@@ -1,9 +1,6 @@
package engine
import (
"slices"
"strings"
"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 {
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
for _, entry := range incoming.Mapping {
position := index.find(existing, entry.Key)
if position < 0 {
continue
}
childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath, index)...)
} else {
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
}
}
} else {
displaced = displacedOrigins(existing, origins, path)
}
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
var displaced []displacedDefinition
collectAffectedOverrideOrigins(&displaced, existing, incoming, origins, path, index)
return sortDisplacedDefinitions(displaced)
}
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 {
collectDisplacedOrigins(displaced, existing, origins, path, Origin{})
return
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origins[path.String()])})
for _, entry := range incoming.Mapping {
position := index.find(existing, entry.Key)
if position < 0 {
continue
}
childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
collectAffectedOverrideOrigins(displaced, childExisting, entry.Value, origins, childPath, index)
} else {
collectDisplacedOrigins(displaced, childExisting, origins, childPath, Origin{})
}
}
}

View File

@@ -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) {
type schemaConfig struct {
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) {
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
if len(diagnostics) != 0 {

View File

@@ -86,7 +86,20 @@ func (p Path) String() string {
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
b.Grow(size)
for _, segment := range p.segments {
b.WriteByte('/')
if segment.kind == pathSegmentIndex {
@@ -115,6 +128,33 @@ func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
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 {
token = strings.ReplaceAll(token, "~", "~0")
return strings.ReplaceAll(token, "/", "~1")

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

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

View File

@@ -1,6 +1,9 @@
package engine
import "testing"
import (
"strconv"
"testing"
)
func TestPathRoundTripsSemanticKeys(t *testing.T) {
t.Parallel()
@@ -36,6 +39,73 @@ func TestPathRoundTripsSemanticKeys(t *testing.T) {
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
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) {

View File

@@ -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 {
t.Helper()
schema, issue := testBuildSchema(reflect.TypeFor[T]())

View File

@@ -26,6 +26,37 @@ type resolutionRecord struct {
reasons []string
dependencies []Path
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 {
@@ -207,9 +238,9 @@ func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason strin
if kind == ChangeNormalization {
record.kind = ChangeNormalization
}
record.reasons = appendUniqueString(record.reasons, reason)
record.addReason(reason)
for _, dependency := range dependencies {
record.dependencies = appendUniquePath(record.dependencies, dependency)
record.addDependency(dependency)
}
if expected != nil {
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)
ordered := orderedResolutionRecords(state.records)
coverage := newResolutionCoverageIndexFromRecords(ordered)
beforeLookup := buildDocumentLookup(decoded.final)
afterLookup := buildDocumentLookup(after)
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")
}
}
for _, record := range ordered {
_, beforePresent := documentAtPath(decoded.final, record.path)
afterNode, afterPresent := documentAtPath(after, record.path)
_, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
continue
@@ -294,8 +328,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
snapshots := make([]resolutionSnapshot, 0, len(ordered))
for _, record := range ordered {
beforeNode, _ := documentAtPath(decoded.final, record.path)
afterNode, _ := documentAtPath(after, record.path)
beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
snapshots = append(snapshots, resolutionSnapshot{
change: Change{
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 {
ordered := make([]*resolutionRecord, 0, len(records))
for _, record := range records {
ordered = append(ordered, record)
type orderedRecord struct {
record *resolutionRecord
pointer string
}
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
return strings.Compare(left.path.String(), right.path.String())
ordered := make([]orderedRecord, 0, len(records))
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 {
var covering *resolutionRecord
for _, record := range records {
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
covering = record
type resolutionCoverageIndex struct {
prefix pathPrefixIndex
depths []int
}
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 {
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
return false
}
if container.String() == "" {
return true
}
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
for i, segment := range container.segments {
if !pathTokenSpellingEqual(segment, descendant.segments[i]) {
return false
}
}
return append(paths, path.clone())
return true
}
func clonePaths(paths []Path) []Path {

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

View File

@@ -122,6 +122,11 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
t.Parallel()
parsedOther, err := ParsePath("/other")
if err != nil {
t.Fatal(err)
}
type config struct {
Value int `yaml:"value"`
Other int `yaml:"other"`
@@ -130,8 +135,9 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
Defaults: func() config { return config{Value: 5} },
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, 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, "updated derived intent", parsedOther)
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) {
t.Parallel()

View File

@@ -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 {
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 {
applicationPaths := make(map[string]*document.Node)
indexDocument(application.value, application.path, applicationPaths)
for pointer := range applicationPaths {
addSecretPath(paths, pointer)
}
walkDocument(application.value, application.path, func(path Path, _ *document.Node) {
addSecretPath(paths, path.String())
})
}
var dependencies []secretDependency
for pointer, history := range metadata.history {
@@ -65,16 +58,19 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
}
secret := make(map[string]bool)
for _, tree := range append(projections, final) {
projected := make(map[string]*document.Node)
indexDocument(tree, Path{}, projected)
for pointer, node := range projected {
classifyProjection := func(tree *document.Node) {
walkDocument(tree, Path{}, func(path Path, node *document.Node) {
pointer := path.String()
addSecretPath(paths, pointer)
if node.Sensitive {
secret[pointer] = true
}
}
})
}
for _, tree := range projections {
classifyProjection(tree)
}
classifyProjection(final)
for pointer, path := range paths {
if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true
@@ -83,49 +79,29 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
secret[pointer] = true
}
}
for {
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
}
}
return closeSecretDependencies(paths, secret, dependencies, inherited)
}
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 {
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) {
if schema == nil || schema.root == nil || !path.valid() {
return Path{}, false

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

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

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

View File

@@ -12,7 +12,11 @@ import (
// Snapshot projection is the shared redaction boundary for effective YAML
// and explanation values. It borrows frozen trees and returns a detached tree.
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 {
return ValueSnapshot{}
}

View File

@@ -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
View 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"]
}
}
}

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

View File

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

View File

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