diff --git a/docs/development.md b/docs/development.md index 9eb3e5e..9539164 100644 --- a/docs/development.md +++ b/docs/development.md @@ -251,15 +251,19 @@ dependency downloads. Acquire dependencies and any selected toolchain beforehand Format reported Go files with `gofmt -w `. The default suite includes executable examples, maintained consumer fixtures, -local documentation-link checks, and the dependency import-boundary check. +and the dependency/public boundary checks. It must stay deterministic and runnable without network access or credentials. Still inspect new/untracked content and review paths, examples, ownership, and current-versus-planned claims; automated checks do not replace content review. 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. +under the testing policy. Documentation-only work normally needs review of +affected links and whitespace; behavior-bearing examples and commands also +need execution. Automated Markdown link checking runs against the committed +candidate through ReleaseKit, not during ordinary Go tests or the everyday +checker. See the [release procedure](release.md#4-publish-the-committed-candidate) +for the selected checks and optional diagnostic command. Use +`go test -race ./...` for ownership or concurrency changes. The [release procedure](release.md#4-publish-the-committed-candidate) performs its selected validation through one `publish` invocation. The everyday checker remains a development gate and is not a separate release prerequisite. CI never @@ -295,7 +299,7 @@ code changes. Use the component overview to find the corresponding implementatio | Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` | | Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` | | Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` | -| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` | +| Dependency and public API isolation | [Import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestDependencyImportBoundary\|TestPublicFacadeBoundary'` | | Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` | ## Fuzz validation diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 9f036a8..456c668 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -50,7 +50,7 @@ Implemented component owners: - Engine mechanism tests own source, composition, inheritance, decoding, callback, provenance, source-context, secret, history, rendering, and bootstrap behavior. Root tests - own public API compatibility, facade integration, documentation links, and + own public API compatibility, facade integration, and dependency/public boundary checks. Consumer examples remain under `examples/consumers`. diff --git a/docs/policy/release.md b/docs/policy/release.md index 80a9966..a61a342 100644 --- a/docs/policy/release.md +++ b/docs/policy/release.md @@ -213,8 +213,8 @@ 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. +includes examples, consumer fixtures, 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. @@ -231,6 +231,17 @@ delivery token path out of the committed candidate. The profile owns the path; the check does not inspect file contents or Git history. Hosted credential preflight belongs to delivery and adds no source-publication gate. +ConfigKit delegates committed Markdown link validation to ReleaseKit rather +than maintaining a documentation-link test in the Go suite. Links outside the +checkout remain out of scope. This moves automated link feedback to candidate +validation; ordinary development review still covers edited links. + +ConfigKit also selects ReleaseKit's local-replacement check for committed module +declarations. Filesystem replacements can make local validation succeed with +dependencies unavailable to consumers of the published module. Versioned module +replacements remain permitted. This check does not validate remote dependency +availability or require consumer retrieval. + ReleaseKit runs its selected Go commands with readonly module mode and `GOPROXY=off`; acquire dependencies and build the tool before candidate validation. Formatting and whitespace remain ordinary development checks in diff --git a/docs/release.md b/docs/release.md index c8c9f9e..3e7b347 100644 --- a/docs/release.md +++ b/docs/release.md @@ -31,7 +31,8 @@ version supported for applications using ConfigKit. Release validation runs the offline Go test suite once with the race detector, then a full vet pass and package compilation for both Linux targets with cgo -disabled (`CGO_ENABLED=0`). macOS and CI are +disabled (`CGO_ENABLED=0`), followed by the selected committed-file checks +described in [publication](#4-publish-the-committed-candidate). macOS and CI are not release gates; do not wait for or poll CI. The vanity module path is the name applications put in Go imports. Source/tag @@ -93,22 +94,18 @@ Prepare dependencies whether or not the minimum changes: go mod download ``` -Build the ReleaseKit version recorded in `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: +Run the module-selected ReleaseKit tool from the repository root. Go builds +and caches the executable; no temporary executable or launcher is needed. +These commands use the version recorded in `go.mod`: ```sh -releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX") -releasekit_bin="$releasekit_tmp/releasekit" -go build -o "$releasekit_bin" go.maximumdirect.net/releasekit/cmd/releasekit go list -m go.maximumdirect.net/releasekit -"$releasekit_bin" version +go tool releasekit version ``` -This repository selects ReleaseKit v0.8.0. Use the compiled program above for -publication so its exit status and publication report reach you unchanged. -`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands +This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the +tool's exit status and publication report; `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. @@ -174,10 +171,10 @@ candidate to `main`, and invoke validation again. ## 4. Publish the committed candidate Once publication is authorized and the candidate is on the publication remote's -`main`, run the compiled ReleaseKit program once to validate and publish it: +`main`, run the module-selected ReleaseKit tool once to validate and publish it: ```sh -"$releasekit_bin" publish vMAJOR.MINOR.PATCH +go tool releasekit publish vMAJOR.MINOR.PATCH ``` Replace the placeholder with the selected version. ReleaseKit checks the @@ -186,14 +183,20 @@ It verifies the candidate on `main`, and checks the release-note H1 for the product and version. It then 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 +arm64. It also checks the committed candidate for the forbidden token path, +filesystem replacements in module declarations, and local Markdown destinations +and supported heading fragments. Markdown links outside the checkout are +reported as out of scope without failing validation. These file checks inspect +the committed candidate, not uncommitted or ignored working-tree content. + +It publishes only the annotated tag for that candidate. Confirmed publication of the intended tag completes the release. The tag points to the captured commit and uses the complete checked-in note as its annotation message. Use the optional `check` command for diagnosis when useful. It validates without publishing and is not required before `publish`, which performs its own checks: ```sh -"$releasekit_bin" check vMAJOR.MINOR.PATCH +go tool releasekit check vMAJOR.MINOR.PATCH ``` For `publish`, interpret the program's exit status as follows: @@ -215,11 +218,11 @@ remote read-back or consumer retrieval; those checks remain optional. ## 5. Deliver the hosted release page After source publication succeeds, run local hosted delivery from the repository -root. Keep the worktree clean and `HEAD` at the published tag's commit. Reuse the -compiled executable from step 1. For the first release using this workflow: +root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the +module-selected tool as in step 1. For the first release using this workflow: ```sh -"$releasekit_bin" deliver v1.0.0 +go tool releasekit deliver v1.0.0 ``` For subsequent releases, substitute the selected version. The profile selects @@ -247,8 +250,8 @@ separately. A delivery problem leaves the successfully published source tag intact. Retry delivery with the same immutable version after correcting the cause and resolving any uncertain write. For a later retry, use a clean checkout of the -original tag, fetching that tag if needed. Build the module-selected CLI from -that checkout using step 1's tool-build commands, without changing the tagged +original tag, fetching that tag if needed. Use that checkout's module-selected +CLI, prepare its dependencies and tool as in step 1 without changing the tagged module files, and make the token available there. For example, with the tag available locally: @@ -260,7 +263,7 @@ git switch --detach "$release_version" After preparing the tool in that tagged checkout: ```sh -"$releasekit_bin" deliver "$release_version" +go tool releasekit deliver "$release_version" ``` Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery. @@ -285,17 +288,17 @@ credential-preflight gate, or required recording-success check. After a failed push, ReleaseKit makes one bounded observation of the original publication endpoint. If the outcome remains unknown, use the explicitly saved record rather than rerunning publication. Copy the recovery command printed by -ReleaseKit, or use the same compiled executable: +ReleaseKit, or use the module-selected tool: ```sh publication_record='/absolute/path/from/the/publication/report.json' -"$releasekit_bin" reconcile --record "$publication_record" +go tool releasekit reconcile --record "$publication_record" ``` If the path was lost, discover local attempts without contacting a remote: ```sh -"$releasekit_bin" publication-attempts +go tool releasekit publication-attempts ``` Select the record for the intended attempt from its reported version, candidate, @@ -334,7 +337,7 @@ by another release channel may it be removed before retrying publication: ```sh release_version='vMAJOR.MINOR.PATCH' git tag -d "$release_version" -"$releasekit_bin" publish "$release_version" +go tool releasekit publish "$release_version" ``` Replace the placeholder with the original attempt's version. diff --git a/documentation_test.go b/documentation_test.go deleted file mode 100644 index 7549a92..0000000 --- a/documentation_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package configkit - -import ( - "bufio" - "fmt" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - "unicode" -) - -var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`) - -func TestLocalDocumentationLinks(t *testing.T) { - repository, err := filepath.Abs(".") - if err != nil { - t.Fatal(err) - } - var failures []string - err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - if entry.Name() == ".git" { - return filepath.SkipDir - } - return nil - } - if filepath.Ext(path) != ".md" { - return nil - } - content, readErr := os.ReadFile(path) - if readErr != nil { - return readErr - } - for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) { - target := strings.Trim(match[1], "<>") - if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") { - continue - } - filePart, fragment, _ := strings.Cut(target, "#") - decoded, decodeErr := url.PathUnescape(filePart) - if decodeErr != nil { - failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target)) - continue - } - linked := path - if decoded != "" { - linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded))) - } - _, statErr := os.Stat(linked) - outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator)) - if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) { - continue // Recorded sibling-source links are optional in isolated clones. - } - if statErr != nil { - failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target)) - continue - } - if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) { - failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target)) - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } - if len(failures) != 0 { - t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n")) - } -} - -func optionalSiblingSource(repository, linked string) bool { - relative, err := filepath.Rel(filepath.Dir(repository), linked) - if err != nil || relative == "." || filepath.IsAbs(relative) { - return false - } - parts := strings.Split(filepath.ToSlash(relative), "/") - if len(parts) == 0 { - return false - } - switch strings.ToLower(parts[0]) { - case "narratio", "notarius", "weatherreporter", "seriatim": - return true - default: - return false - } -} - -func markdownHasFragment(path, wanted string) bool { - file, err := os.Open(path) - if err != nil { - return false - } - defer file.Close() - wanted, err = url.PathUnescape(wanted) - if err != nil { - return false - } - seen := make(map[string]int) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if !strings.HasPrefix(line, "#") { - continue - } - heading := strings.TrimSpace(strings.TrimLeft(line, "#")) - base := markdownFragment(heading) - count := seen[base] - seen[base]++ - fragment := base - if count > 0 { - fragment = fmt.Sprintf("%s-%d", base, count) - } - if fragment == wanted { - return true - } - } - return false -} - -func markdownFragment(heading string) string { - var result strings.Builder - previousSpace := false - for _, character := range strings.ToLower(heading) { - switch { - case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-': - result.WriteRune(character) - previousSpace = false - case unicode.IsSpace(character): - if result.Len() > 0 && !previousSpace { - result.WriteByte('-') - previousSpace = true - } - } - } - return strings.TrimRight(result.String(), "-") -} - -func relativePath(root, path string) string { - relative, err := filepath.Rel(root, path) - if err != nil { - return path - } - return filepath.ToSlash(relative) -} diff --git a/releasekit.json b/releasekit.json index 9bceb8d..5c3396c 100644 --- a/releasekit.json +++ b/releasekit.json @@ -4,6 +4,10 @@ "expected_push_url": "ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git", "validation": { "forbidden_files": [".releasekit/gitea-token"], + "markdown": {}, + "module_distribution": { + "local_replacements": true + }, "go": { "race": true, "full_vet": true,