diff --git a/build_test.go b/build_test.go index fee9f55..bc27851 100644 --- a/build_test.go +++ b/build_test.go @@ -432,6 +432,9 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) { func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) { repository := newPublishFixture(t) + writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n") + gitRun(t, repository.work, "add", "guide.md") + gitRun(t, repository.work, "commit", "-m", "add Markdown guide") prepareCandidateBuild(t, repository.work, `package main import ( "fmt" @@ -445,6 +448,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt RepositoryDir: repository.work, Product: "Sample App", Version: "v1.2.3", + Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}}, AdditionalChecks: func(context.Context, releasekit.Candidate) error { validations++ return nil diff --git a/check.go b/check.go index fde89d9..8eae41a 100644 --- a/check.go +++ b/check.go @@ -52,6 +52,10 @@ type ValidationSpec struct { // GoFormat checks the formatting of committed regular Go source files. It // does not modify repository contents and is disabled when false. GoFormat bool + // Markdown selects committed Markdown destination and fragment checking. + // Nil disables the check; an empty object enables it with the default + // behavior, reporting outside-checkout destinations as out of scope. + Markdown *MarkdownValidationSpec // ModuleDistribution selects checks for files that affect module-source // distribution. Its zero value disables those checks. ModuleDistribution ModuleDistributionValidationSpec @@ -60,6 +64,13 @@ type ValidationSpec struct { FileSize *FileSizeValidationSpec } +// MarkdownValidationSpec selects behavior for committed Markdown links. +type MarkdownValidationSpec struct { + // EnforceCheckoutBoundary fails when a local destination resolves outside + // the candidate checkout. Submodule contents remain out of scope. + EnforceCheckoutBoundary bool +} + // FileSizeValidationSpec selects a maximum size for each committed blob. type FileSizeValidationSpec struct { // LimitBytes is the maximum decimal byte count. Nil selects 1,000,000; @@ -107,14 +118,19 @@ type GoValidationSpec struct { // Validation.GoFormat checks regular committed Go source after those selected // Go checks and before AdditionalChecks. Its zero value disables formatting. // Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace -// files after the other selected shared checks. Its zero value disables the -// distribution check and does not change Go's workspace environment. +// files after formatting. Its zero value disables that distribution check and +// does not change Go's workspace environment. // Validation.ModuleDistribution.LocalReplacements parses committed go.mod // declarations, including nested modules, and rejects filesystem replacements // after workspace-file inspection. Its zero value disables that check. // Validation.FileSize checks each selected committed blob after module // distribution checks. Its nil value disables size checks; an empty object // enables the default limit. +// Validation.Markdown checks committed local Markdown destinations and +// supported fragments after file-size inspection and before AdditionalChecks. +// Nil disables the check; an empty object enables it. Outside-checkout +// destinations are reported as out of scope unless the selected specification +// enforces the checkout boundary. Submodule contents remain out of scope. // The zero values of all selections preserve the ordinary suite. If // Build selects targets, Check builds them after validation in temporary // storage outside the worktree, then rechecks @@ -143,6 +159,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request { FullVet: request.Validation.Go.FullVet, CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), GoFormat: request.Validation.GoFormat, + Markdown: internalMarkdownSettings(request.Validation.Markdown), ModuleDistribution: validation.ModuleDistributionSettings{ WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles, LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements, @@ -159,6 +176,13 @@ func candidateRequest(request ReleaseRequest) candidate.Request { return result } +func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings { + if spec == nil { + return nil + } + return &validation.MarkdownSettings{EnforceCheckoutBoundary: spec.EnforceCheckoutBoundary} +} + func internalFileSizeSettings(spec *FileSizeValidationSpec) *validation.FileSizeSettings { if spec == nil { return nil diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 91fb6d7..2c8ed6c 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -130,12 +130,20 @@ type ValidationSettings struct { // GoFormat checks the formatting of committed regular Go source files. // Omitted or false disables the check. GoFormat bool `json:"go_format,omitempty"` + Markdown *MarkdownSettings `json:"markdown,omitempty"` ModuleDistribution ModuleDistributionSettings `json:"module_distribution,omitempty"` // FileSize checks the size of each committed blob. Null or omission disables // the check; an empty object enables the default limit. FileSize *FileSizeSettings `json:"file_size,omitempty"` } +// MarkdownSettings selects committed Markdown destination and fragment checks. +type MarkdownSettings struct { + // EnforceCheckoutBoundary fails local destinations that resolve outside + // the candidate checkout. Submodule contents remain out of scope. + EnforceCheckoutBoundary bool `json:"enforce_checkout_boundary,omitempty"` +} + // FileSizeSettings selects the maximum committed-file size and literal exclusions. type FileSizeSettings struct { LimitBytes *int64 `json:"limit_bytes,omitempty"` diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index 3b4c2d7..7402adc 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") - if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"module_distribution":{"workspace_files":true,"local_replacements":true},"file_size":{"limit_bytes":0,"exclusions":["assets"]},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { + if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"markdown":{"enforce_checkout_boundary":true},"module_distribution":{"workspace_files":true,"local_replacements":true},"file_size":{"limit_bytes":0,"exclusions":["assets"]},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { t.Fatal(err) } @@ -152,7 +152,7 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { + if settings.Validation == nil || !settings.Validation.GoFormat || settings.Validation.Markdown == nil || !settings.Validation.Markdown.EnforceCheckoutBoundary || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { t.Fatalf("validation settings = %#v, want Go formatting, workspace inspection, race, and full vet enabled", settings.Validation) } if settings.Validation.FileSize == nil || settings.Validation.FileSize.LimitBytes == nil || *settings.Validation.FileSize.LimitBytes != 0 || len(settings.Validation.FileSize.Exclusions) != 1 || settings.Validation.FileSize.Exclusions[0] != "assets" { @@ -166,6 +166,35 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { } } +func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) { + repoDir := t.TempDir() + profilePath := filepath.Join(repoDir, "releasekit.json") + for _, test := range []struct { + name string + profile string + want bool + }{ + {name: "omitted", profile: `{"product":"Example","validation":{}}`}, + {name: "null", profile: `{"product":"Example","validation":{"markdown":null}}`}, + {name: "empty object", profile: `{"product":"Example","validation":{"markdown":{}}}`, want: true}, + {name: "boundary enabled", profile: `{"product":"Example","validation":{"markdown":{"enforce_checkout_boundary":true}}}`, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil { + t.Fatal(err) + } + settings, err := LoadSettings(repoDir, "") + if err != nil { + t.Fatal(err) + } + got := settings.Validation != nil && settings.Validation.Markdown != nil + if got != test.want { + t.Fatalf("Markdown selected = %t, want %t", got, test.want) + } + }) + } +} + func TestLoadSettingsPreservesEmptyAndNullFileSize(t *testing.T) { for _, test := range []struct { name string diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 1fdbaac..76b20dd 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -172,6 +172,11 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)), }, } + if settings.Markdown != nil { + result.Markdown = &releasekit.MarkdownValidationSpec{ + EnforceCheckoutBoundary: settings.Markdown.EnforceCheckoutBoundary, + } + } if settings.FileSize != nil { var limitBytes *int64 if settings.FileSize.LimitBytes != nil { diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 809bd41..662ae54 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -119,6 +119,7 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { ExpectedPushURL: "publication-only value", Validation: &cli.ValidationSettings{ GoFormat: true, + Markdown: &cli.MarkdownSettings{EnforceCheckoutBoundary: true}, Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true, LocalReplacements: true}, FileSize: &cli.FileSizeSettings{LimitBytes: &limit, Exclusions: []string{"cache"}}, @@ -130,6 +131,9 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { if !request.Validation.GoFormat { t.Fatalf("candidate request GoFormat = false, want true") } + if request.Validation.Markdown == nil || !request.Validation.Markdown.EnforceCheckoutBoundary { + t.Fatalf("candidate request Markdown = %#v, want boundary enforcement enabled", request.Validation.Markdown) + } if !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements { t.Fatalf("candidate request module distribution = %#v, want both checks enabled", request.Validation.ModuleDistribution) } @@ -150,6 +154,58 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { } } +func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) { + for _, test := range []struct { + name string + settings *cli.MarkdownSettings + want bool + }{ + {name: "omitted"}, + {name: "empty object", settings: &cli.MarkdownSettings{}, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + got := validationSpec(&cli.ValidationSettings{Markdown: test.settings}).Markdown != nil + if got != test.want { + t.Fatalf("Markdown selected = %t, want %t", got, test.want) + } + }) + } +} + +func TestCandidateCommandsRunMarkdownProfile(t *testing.T) { + repository := newCLIRepository(t) + profile, err := json.Marshal(cli.Settings{ + Product: "Example Product", + Validation: &cli.ValidationSettings{ + Markdown: &cli.MarkdownSettings{}, + }, + }) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repository, "releasekit.json", string(profile)) + writeRepositoryFile(t, repository, "guide.md", "[missing](missing.md#top)\n") + cliGitRun(t, repository, "add", "releasekit.json", "guide.md") + cliGitRun(t, repository, "commit", "-m", "configure Markdown candidate validation") + remote := filepath.Join(t.TempDir(), "origin.git") + cliGitRun(t, repository, "init", "--bare", remote) + cliGitRun(t, repository, "remote", "add", "origin", remote) + cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main") + + for _, command := range []string{"check", "publish"} { + t.Run(command, func(t *testing.T) { + var stdout, stderr bytes.Buffer + args := []string{command, "--repo", repository, "v1.2.3"} + if status := run(context.Background(), args, &stdout, &stderr); status != 1 { + t.Fatalf("run() status = %d; stdout=%q stderr=%q, want validation failure", status, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "markdown-links") || !strings.Contains(stderr.String(), "guide.md:1:") || !strings.Contains(stderr.String(), "missing.md#top") { + t.Fatalf("stderr = %q, want Markdown identity, source location, and target", stderr.String()) + } + }) + } +} + func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) { repository := newCLIRepository(t) profile, err := json.Marshal(cli.Settings{Product: "Example Product"}) diff --git a/docs/cli.md b/docs/cli.md index 445b79f..8095be5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,6 +44,14 @@ modifying files. When `validation.module_distribution.workspace_files` is true, and symlink entries, as `workspace-files`. This inspection is independent of `GOWORK` and ignores uncommitted files. Any artifact build selected by the separate `build` setting runs after shared validation and configured checks. +When `validation.markdown` is a non-null object, `check` checks committed local +Markdown destinations and supported heading fragments after file-size +inspection. An empty object enables the check; omission or `null` disables it. +Outside-checkout links are written as out-of-scope observations to standard +error by default. Setting `validation.markdown.enforce_checkout_boundary` to +true makes those links fail; submodule-content observations remain +nonfailures. Failures use the `markdown-links` identity with the source location +and target. `check` writes errors and diagnostics to standard error. It returns status 0 after local validation, 1 after a candidate validation failure, and 2 for usage, @@ -64,8 +72,9 @@ candidate: the single standard offline Go test suite runs first, with `-race` when `validation.go.race` is true. When `validation.go.full_vet` is true, test-time vet is disabled and `go vet -mod=readonly ./...` runs after successful tests. Selected package compilation and committed Go formatting follow. The -selected workspace-file check then runs, followed by configured checks in -profile order. It then creates an annotated local tag +selected file checks then run in order: workspace files, local replacements, +file size, and Markdown. Configured checks run afterward in profile order. It +then creates an annotated local tag and pushes only its exact tag object. It prints the confirmed version, candidate commit, and tag object on success. Validation @@ -97,6 +106,11 @@ disables the check; an empty object enables the 1,000,000 decimal-byte default. Oversized files use the `file-size` identity and report their path and measured size against the limit. See the [configuration reference](config.md) for literal exclusions and the current-candidate scope. +When `validation.markdown` is a non-null object, `publish` checks committed +Markdown destinations after file-size inspection and before configured checks. +It reports outside-checkout links to standard error unless boundary enforcement +is selected; submodule contents remain out of scope. Markdown failures stop +publication before product checks or tag creation. The optional profile field `expected_push_url` applies only to `publish`. A nonempty value must exactly match the effective push URL resolved by Git; diff --git a/docs/config.md b/docs/config.md index a691f41..7d5e14b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -46,6 +46,26 @@ change under formatting. It never writes formatted bytes back. Omitted or false disables the check. It runs after tests, selected full vet, and selected package compilation, before configured product checks. +When `validation.markdown` is an object, ReleaseKit checks local destinations +and supported heading fragments in committed regular Markdown files. Omission +or `null` disables the check; `{}` enables it. Schemed and protocol-relative +links are skipped, URI query components do not affect path checks, and other +existing files or directories receive existence checks only. Missing local +destinations, symlink cycles, and missing Markdown headings fail with the +`markdown-links` identity. Outside-checkout destinations are reported as out +of scope by default. Set +`validation.markdown.enforce_checkout_boundary` to `true` to fail those links; +submodule contents remain out of scope under either setting. + +The parser recognizes inline links, images, reference links, and CommonMark +autolinks; link-like text in code is ignored, and raw HTML destinations are +outside this check. Relative paths resolve from the source document in the +captured commit. Query components are ignored for file existence, and percent +escapes in local paths and fragments are decoded once. Fragments are compared +in lowercase with anchors derived from visible ATX or Setext heading text. +Anchors keep Unicode letters and numbers, underscores, and hyphens; spaces +become hyphens, and repeated headings receive numeric suffixes. + When `validation.module_distribution.workspace_files` is true, it reports committed blob entries whose basename is `go.work` or `go.work.sum`, including nested paths and symlinks. It checks candidate distribution contents regardless @@ -71,6 +91,7 @@ For example: "compile_targets": ["linux/amd64", "linux/arm64"] }, "go_format": true, + "markdown": {}, "module_distribution": { "workspace_files": true, "local_replacements": true @@ -130,6 +151,10 @@ preserve their causes. When `validation.file_size` is selected, shared validation checks committed blob sizes after module-distribution checks and reports the first oversized path with the `file-size` identity and measured and limit byte counts. +When `validation.markdown` is selected, Markdown checking follows file-size +inspection and runs before configured product checks. Out-of-scope observations +are written to the command's standard error and do not fail validation unless +the checkout boundary is enforced. Submodule observations remain nonfailures. Configured `checks` commands run afterward, once each in order, as additional product checks. Artifact builds selected by the separate `build` setting run after shared validation and product checks. diff --git a/docs/consumers/check.md b/docs/consumers/check.md index a577e9a..47c65d2 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -62,6 +62,20 @@ directories; excluded directories include descendants. Symlink blobs are measured by link-text bytes, and gitlinks are omitted. The check applies to the captured candidate and reports one oversized file with its measurement as `file-size`; it does not measure historical or aggregate size. +Set `Validation.Markdown` to check committed local Markdown destinations and +supported heading fragments. Nil disables the check; an empty object enables +it with outside-checkout destinations reported as out of scope. Set +`EnforceCheckoutBoundary` to fail outside-checkout links. Submodule contents +remain out of scope under either setting. The checker runs after file-size +inspection and before `AdditionalChecks`; its nonfailure observations use the +request's output writer. Markdown failures use the `markdown-links` identity +and include the source location and destination. + +Use these selections for ReleaseKit-owned validation rather than repeating the +standard suite, selected vet/format/distribution checks, or Markdown navigation +checks in `AdditionalChecks` or profile `checks`. Keep release-note rules and +consumer-specific assertions—such as tests of maintained examples—in those +product-owned checks. ReleaseKit calls `AdditionalChecks` once after shared validation when supplied. It does not download a different toolchain automatically. If race prerequisites are unavailable, the `go-test` check fails rather than being skipped; a selected diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index e55a276..477fb5f 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -78,6 +78,19 @@ blob size is its link text; gitlinks are omitted. This check measures only the captured candidate and reports one oversized file with its measurement as `file-size`; it stops publication before product checks or tag creation. +Set `Validation.Markdown` to check committed local Markdown destinations and +supported heading fragments during publication. Nil disables the check; an +empty object enables it with outside-checkout links reported as out of scope. +Set `EnforceCheckoutBoundary` to fail those links. Submodule contents remain +out of scope under either setting. Observations use the request's output writer; +failures use `markdown-links` and stop publication before product checks or tag +creation. + +ReleaseKit-owned selections replace duplicate generic suite, vet, formatting, +distribution, and Markdown checks in configured product checks. Keep +release-note rules and consumer-specific assertions, such as maintained-example +tests, in `AdditionalChecks` or profile `checks`. + If `Build` selects targets, ReleaseKit verifies them after the standard suite and additional checks, before creating the tag. The operation then rechecks the candidate and remote guards, creates an annotated tag for the captured commit, diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 6971a04..1cec3bc 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -46,7 +46,7 @@ reports these implemented packages: | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | -| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, internal Markdown parsing, destination and fragment checking, and per-document anchor caching; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | +| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | @@ -78,8 +78,8 @@ not a separate release matrix: | Capability | Implementation owner | Existing evidence | | --- | --- | --- | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | -| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestLoadSettingsReadsGoValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | -| Internal Markdown parsing, committed destinations, fragments, boundary observations, and causes | `internal/validation` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause` | +| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | +| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | diff --git a/docs/internal/validation.md b/docs/internal/validation.md index 320c9e9..27a2ba2 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -41,6 +41,10 @@ The optional file-size check uses each blob's captured Git size, including symlink link-text bytes, and skips entries without a blob size such as gitlinks. It applies normalized literal file/directory exclusions with path-boundary matching, then returns the first oversized path with `FileSizeMeasurement`. +The optional Markdown check follows file-size inspection and uses the same +candidate tree. It parses regular Markdown blobs, resolves local destinations, +and checks supported anchors; outside-checkout and submodule observations are +written to the shared output writer. A nil writer keeps library use quiet. `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes lazily, and resolves destinations without consulting the worktree or neighboring files. @@ -61,8 +65,8 @@ paths, symlink cycles, and missing anchors produce `markdown-links` findings at the source use site. Outside-checkout links produce out-of-scope observations by default and findings when boundary enforcement is selected. Submodule-content observations remain out of scope under either setting. Observations go to the -provided writer; a nil writer keeps library use quiet. Public selection remains -in the [implementation plan](../roadmap/implementation.md). +provided writer; a nil writer keeps library use quiet. Public selection is +defined in the [configuration reference](../config.md) and GoDoc. Committed-tree metadata and blob reads disable lazy fetching and Git transports for those child commands. Missing local objects produce inspection errors; @@ -136,6 +140,7 @@ symlink expansion detects cycles, and traversal honors cancellation. Absolute destinations and symlink targets within the repository directory map back to candidate-relative paths; existence still comes from committed entries. -The [implementation plan](../roadmap/implementation.md) records the remaining -public Markdown selection and integration work. The internal checker uses the -committed-file and destination-resolution primitives described here. +The [implementation plan](../roadmap/implementation.md) records the completed +Markdown integration. The public selection is owned by the facade and profile; +the internal checker uses the committed-file and destination-resolution +primitives described here. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 12475e2..5cbfb24 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -446,8 +446,8 @@ Undefined references and HTML prose remain governed by parser interpretation. **Completion:** Complete. The internal checker resolves committed destinations, checks supported fragments, preserves inspection errors, and reports distinct -outside-checkout and submodule observations. Public selection and shared executor -wiring remain in Stage 11. +outside-checkout and submodule observations. Stage 11 exposes this checker +through public and profile selections and the shared executor. **Outcome:** Committed Markdown navigation is checked with boundary observations. @@ -477,6 +477,14 @@ missing-object, and traversal-cancellation cases. ## Stage 11: Expose Markdown selection and complete validation integration +**Completion:** Complete. `ValidationSpec.Markdown` and +`validation.markdown` select the shared committed-link checker through both +public operations and CLI profile commands. Omitted/null settings disable the +check; an empty object enables it, and the boundary option maps explicitly. +Warnings use the shared output writer, while Markdown failures stop product +checks and builds. GoDoc, profile, consumer, CLI, and internal documentation +now describe the operational behavior. + **Outcome:** All agreed validation selections work through both public operations. 1. Add `MarkdownValidationSpec`, `ValidationSpec.Markdown`, and their JSON fields. diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index 37b98a3..18fda3e 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -45,12 +45,16 @@ func TestValidStableVersion(t *testing.T) { func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") + writeTestFile(t, repository, "guide.md", "# Guide\n[section](#guide)\n") + gitRun(t, repository, "add", "guide.md") + gitRun(t, repository, "commit", "-m", "add Markdown guide") calls := 0 var seen Candidate candidate, err := Check(context.Background(), Request{ RepositoryDir: repository, Product: "Example Product", Version: testVersion, + Validation: validation.Settings{Markdown: &validation.MarkdownSettings{}}, AdditionalChecks: func(_ context.Context, captured Candidate) error { calls++ seen = captured @@ -108,6 +112,38 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) } } +func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *testing.T) { + repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") + writeTestFile(t, repository, "guide.md", "[outside](../../outside.md)\n") + gitRun(t, repository, "add", "guide.md") + gitRun(t, repository, "commit", "-m", "add outside Markdown destination") + productCalls := 0 + buildCalls := 0 + _, err := Check(context.Background(), Request{ + RepositoryDir: repository, + Product: "Example Product", + Version: testVersion, + Validation: validation.Settings{ + Markdown: &validation.MarkdownSettings{EnforceCheckoutBoundary: true}, + }, + AdditionalChecks: func(context.Context, Candidate) error { + productCalls++ + return nil + }, + AfterValidate: func(context.Context, Candidate) error { + buildCalls++ + return nil + }, + }) + var failure *validation.Failure + if !errors.As(err, &failure) || failure.Check != validation.MarkdownLinks { + t.Fatalf("Check() error = %v, want markdown-links failure", err) + } + if productCalls != 0 || buildCalls != 0 { + t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after Markdown failure", productCalls, buildCalls) + } +} + func TestCheckReportsCommittedWorkspaceFilesWhenGoWorkspaceIsDisabled(t *testing.T) { t.Setenv("GOWORK", "off") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") diff --git a/internal/validation/markdown_check_test.go b/internal/validation/markdown_check_test.go index ebbeb77..958b1d4 100644 --- a/internal/validation/markdown_check_test.go +++ b/internal/validation/markdown_check_test.go @@ -192,6 +192,49 @@ func TestCheckMarkdownLinksPreservesAnchorReadCause(t *testing.T) { } } +func TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks(t *testing.T) { + repository, _ := committedMarkdownTree(t, map[string][]byte{ + "guide.md": []byte("[outside](../../outside.md)\n"), + }) + commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")) + goLog := installGoShim(t, "", false) + var output bytes.Buffer + err := Run(context.Background(), Request{ + RepositoryDir: repository, + Commit: commit, + Output: &output, + Settings: Settings{ + Race: true, + FullVet: true, + CompileTargets: []Target{{OS: "linux", Arch: "amd64"}}, + Markdown: &MarkdownSettings{}, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v, want out-of-scope observation to succeed", err) + } + if got := output.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "out of scope") { + t.Fatalf("Run() output = %q, want outside-checkout observation", got) + } + commands := readGoShimLog(t, goLog) + var testCommands []string + var validationCommands []string + for _, command := range commands { + if strings.HasPrefix(command, "test ") { + testCommands = append(testCommands, command) + } + if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") { + validationCommands = append(validationCommands, command) + } + } + if len(testCommands) != 1 || !strings.Contains(testCommands[0], "-race") || !strings.Contains(testCommands[0], "-vet=off") { + t.Fatalf("Go test commands = %#v, want one race-enabled suite with test-time vet disabled", testCommands) + } + if len(validationCommands) != 3 || !strings.HasPrefix(validationCommands[0], "test ") || !strings.HasPrefix(validationCommands[1], "vet ") || !strings.HasPrefix(validationCommands[2], "build ") { + t.Fatalf("Go validation commands = %#v, want test, full vet, and target compilation before Markdown", validationCommands) + } +} + func committedMarkdownTree(t *testing.T, files map[string][]byte) (string, *CandidateTree) { t.Helper() repository := newTreeRepository(t) diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 23c91cd..3328934 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -20,10 +20,16 @@ type Settings struct { FullVet bool CompileTargets []Target GoFormat bool + Markdown *MarkdownSettings ModuleDistribution ModuleDistributionSettings FileSize *FileSizeSettings } +// MarkdownSettings selects committed Markdown destination and fragment checks. +type MarkdownSettings struct { + EnforceCheckoutBoundary bool +} + // FileSizeSettings selects a per-blob limit and literal path exclusions. type FileSizeSettings struct { LimitBytes *int64 @@ -112,10 +118,13 @@ func Run(ctx context.Context, request Request) error { } } } - if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil { + if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil { tree, err := request.ReadCandidateTree(ctx) if err != nil { - check := FileSize + check := MarkdownLinks + if request.Settings.FileSize != nil { + check = FileSize + } if request.Settings.ModuleDistribution.LocalReplacements { check = LocalReplacements } @@ -147,6 +156,11 @@ func Run(ctx context.Context, request Request) error { return err } } + if request.Settings.Markdown != nil { + if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil { + return err + } + } } return nil } diff --git a/publish.go b/publish.go index a64572c..39fa56a 100644 --- a/publish.go +++ b/publish.go @@ -54,9 +54,14 @@ type PublicationResult struct { // Validation.Go.FullVet runs go vet afterward instead of test-time vet, and // Validation.Go.CompileTargets compiles all packages for each selected target // with cgo disabled after tests and vet. Compilation produces no artifacts and -// does not execute target binaries. ExpectedPushURL, when nonempty, is compared -// exactly with the resolved effective push URL before validation; a mismatch -// returns ErrUnexpectedPushURL. It then calls AdditionalChecks when supplied. +// does not execute target binaries. The selected formatting, distribution, +// file-size, and Markdown checks then run before AdditionalChecks. Markdown is +// disabled when nil and enabled by a non-nil specification; outside-checkout +// destinations are reported as out of scope unless boundary enforcement is +// selected. Submodule contents remain out of scope. ExpectedPushURL, when +// nonempty, is compared exactly with the resolved effective push URL before +// validation; a mismatch returns ErrUnexpectedPushURL. It then calls +// AdditionalChecks when supplied. // It optionally builds selected targets in temporary storage outside the // worktree and pushes the exact annotated tag object to the selected remote. // It returns a result with errors: preflight failures are not_attempted, failed diff --git a/publish_test.go b/publish_test.go index ac51e76..6ddf8f3 100644 --- a/publish_test.go +++ b/publish_test.go @@ -302,6 +302,62 @@ func TestCheckReturnsPublicValidationError(t *testing.T) { } } +func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) { + addBrokenMarkdownDestination := func(t *testing.T, repo publishFixture) { + t.Helper() + writeFile(t, repo.work, "guide.md", "[missing](missing.md#top)\n") + gitRun(t, repo.work, "add", "guide.md") + gitRun(t, repo.work, "commit", "-m", "add broken Markdown destination") + } + assertMarkdownFailure := func(t *testing.T, err error) { + t.Helper() + var failure *releasekit.ValidationError + if !errors.As(err, &failure) || failure.Check != "markdown-links" { + t.Fatalf("operation error = %v, want public markdown-links failure", err) + } + if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "missing.md#top" || failure.Findings[0].Line != 1 { + t.Fatalf("Markdown findings = %#v, want source location and target", failure.Findings) + } + } + + t.Run("check", func(t *testing.T) { + repo := newPublishFixture(t) + addBrokenMarkdownDestination(t, repo) + _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}}, + }) + assertMarkdownFailure(t, err) + }) + + t.Run("publish", func(t *testing.T) { + repo := newPublishFixture(t) + addBrokenMarkdownDestination(t, repo) + gitRun(t, repo.work, "config", "--unset", "remote.origin.mirror") + gitRun(t, repo.work, "push", "origin", "HEAD:refs/heads/main") + callbackCalls := 0 + _, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}}, + AdditionalChecks: func(context.Context, releasekit.Candidate) error { + callbackCalls++ + return nil + }, + }) + assertMarkdownFailure(t, err) + if callbackCalls != 0 { + t.Fatalf("additional checks called %d times after Markdown failure", callbackCalls) + } + if tag := gitText(t, repo.work, "tag", "--list", "v1.2.3"); tag != "" { + t.Fatalf("failed Markdown validation created tag %q", tag) + } + }) +} + func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) { repo := newPublishFixture(t) suiteRuns := filepath.Join(t.TempDir(), "suite-runs") diff --git a/validation_error.go b/validation_error.go index 8580189..a4928be 100644 --- a/validation_error.go +++ b/validation_error.go @@ -9,7 +9,7 @@ import ( // ValidationCheck identifies a shared validation check that failed. // Current checks include "go-test", "go-vet", "package-compile", "go-format", -// "workspace-files", "local-replacements", and "file-size". +// "workspace-files", "local-replacements", "file-size", and "markdown-links". type ValidationCheck string // ValidationFinding describes a failed check observation at an optional diff --git a/validation_test.go b/validation_test.go index 747ae44..d6960f7 100644 --- a/validation_test.go +++ b/validation_test.go @@ -13,11 +13,14 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { Validation: ValidationSpec{Go: GoValidationSpec{ Race: true, FullVet: true, CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, - }, GoFormat: true, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}, FileSize: &FileSizeValidationSpec{LimitBytes: &limit, Exclusions: []string{"assets"}}}, + }, GoFormat: true, Markdown: &MarkdownValidationSpec{EnforceCheckoutBoundary: true}, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}, FileSize: &FileSizeValidationSpec{LimitBytes: &limit, Exclusions: []string{"assets"}}}, }) if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat || !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements { t.Fatalf("candidate request validation = %#v, want race, full vet, formatting, and distribution checks enabled", request.Validation) } + if request.Validation.Markdown == nil || !request.Validation.Markdown.EnforceCheckoutBoundary { + t.Fatalf("candidate request Markdown = %#v, want boundary enforcement enabled", request.Validation.Markdown) + } if request.Validation.FileSize == nil || request.Validation.FileSize.LimitBytes == nil || *request.Validation.FileSize.LimitBytes != limit || len(request.Validation.FileSize.Exclusions) != 1 || request.Validation.FileSize.Exclusions[0] != "assets" { t.Fatalf("candidate request file size = %#v, want configured limit and exclusions", request.Validation.FileSize) } @@ -32,6 +35,24 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { } } +func TestCandidateRequestPreservesMarkdownObjectSelection(t *testing.T) { + for _, test := range []struct { + name string + spec *MarkdownValidationSpec + want bool + }{ + {name: "omitted"}, + {name: "empty object", spec: &MarkdownValidationSpec{}, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + request := candidateRequest(ReleaseRequest{Validation: ValidationSpec{Markdown: test.spec}}) + if got := request.Validation.Markdown != nil; got != test.want { + t.Fatalf("Markdown selected = %t, want %t", got, test.want) + } + }) + } +} + func TestCandidateRequestPreservesFileSizeObjectSelection(t *testing.T) { disabled := candidateRequest(ReleaseRequest{}) enabled := candidateRequest(ReleaseRequest{Validation: ValidationSpec{FileSize: &FileSizeValidationSpec{}}})