diff --git a/check.go b/check.go index 76d0adf..908dc2a 100644 --- a/check.go +++ b/check.go @@ -49,6 +49,9 @@ type ReleaseRequest struct { // preserves the current validation behavior. type ValidationSpec struct { Go GoValidationSpec + // GoFormat checks the formatting of committed regular Go source files. It + // does not modify repository contents and is disabled when false. + GoFormat bool } // GoValidationSpec selects checks performed by the Go toolchain. @@ -74,7 +77,9 @@ type GoValidationSpec struct { // of test-time vet, and Validation.Go.CompileTargets compiles all packages for // each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package // compilation produces no artifacts and does not execute target binaries. -// Their zero values preserve the ordinary suite. If +// Validation.GoFormat checks regular committed Go source after those selected +// Go checks and before AdditionalChecks. Its zero value disables formatting. +// 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 // branch, HEAD, worktree, index, and local tag state. Success establishes local @@ -100,6 +105,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request { Race: request.Validation.Go.Race, FullVet: request.Validation.Go.FullVet, CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), + GoFormat: request.Validation.GoFormat, }, AdditionalChecks: candidateChecks(request.AdditionalChecks), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 55538ea..51b543d 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -127,6 +127,9 @@ type Settings struct { // ValidationSettings selects shared candidate checks in a release profile. type ValidationSettings struct { Go GoValidationSettings `json:"go,omitempty"` + // GoFormat checks the formatting of committed regular Go source files. + // Omitted or false disables the check. + GoFormat bool `json:"go_format,omitempty"` } // GoValidationSettings selects checks performed by the Go toolchain. diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index 735d559..3c7942a 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":{"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,"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { t.Fatal(err) } @@ -152,8 +152,8 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.Validation == nil || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { - t.Fatalf("validation settings = %#v, want Go race and full vet enabled", settings.Validation) + if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { + t.Fatalf("validation settings = %#v, want Go formatting, race, and full vet enabled", settings.Validation) } if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want { t.Fatalf("compile targets = %q, want %q", got, want) diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index d0a3257..37e561b 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -161,6 +161,7 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec return releasekit.ValidationSpec{} } result := releasekit.ValidationSpec{ + GoFormat: settings.GoFormat, Go: releasekit.GoValidationSpec{ Race: settings.Go.Race, FullVet: settings.Go.FullVet, diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 163afa2..55e8f63 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -117,12 +117,16 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { Product: "Example Product", ExpectedPushURL: "publication-only value", Validation: &cli.ValidationSettings{ - Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, + GoFormat: true, + Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, }, }, nil) if !request.Validation.Go.Race || !request.Validation.Go.FullVet { t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation) } + if !request.Validation.GoFormat { + t.Fatalf("candidate request GoFormat = false, want true") + } if request.ExpectedPushURL != "" { t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL) } diff --git a/docs/cli.md b/docs/cli.md index ea63dac..09fac25 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,6 +27,7 @@ captured local commit and version. It checks the committed release note and local Git state, runs the standard offline Go test suite (with `-race` when `validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...` pass after successful tests when `validation.go.full_vet` is true. It then runs +selected package compilation and committed Go formatting, followed by configured additional checks in profile order. It does not inspect remote availability or publish a tag. @@ -34,7 +35,11 @@ When `validation.go.compile_targets` is selected, `check` compiles all packages for each configured `GOOS/GOARCH` pair after tests and full vet, using static mode with cgo disabled. This check produces no artifacts and does not execute target binaries. Compilation failures use the `package-compile` identity and -show the selected target. Any artifact build selected by the separate `build` +show the selected target. When `validation.go_format` is true, `check` examines +regular committed Go files with the Go formatter after tests, vet, and package +compilation. It includes vendor and nested-module source, ignores the worktree +and symlink blobs, and reports unformatted paths as `go-format` without +modifying files. Any artifact build selected by the separate `build` setting runs after shared validation and configured checks. `check` writes errors and diagnostics to standard error. It returns status 0 @@ -55,8 +60,9 @@ settings as `check`. It checks the authoritative remote and validates the 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. Configured checks then run in profile order. It then creates an annotated -local tag and pushes only its exact tag object. It prints the confirmed version, +tests. Selected package compilation and committed Go formatting follow, then +configured checks run 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 progress and failed publication state, including the captured publication @@ -69,7 +75,10 @@ Shared validation failures use the same check and finding presentation as When `validation.go.compile_targets` is selected, package compilation runs after tests and full vet and before configured checks. Each failure is reported as `package-compile` with its selected target; this does not create build -artifacts or execute target binaries. +artifacts or execute target binaries. When `validation.go_format` is true, +committed Go formatting runs after package compilation and before configured +checks. A failure is reported as `go-format` with affected repository paths; +parser and source-inspection causes are retained. 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 0217a93..c7d98f6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,16 +39,25 @@ This source compatibility check does not name or produce binaries, run a target executable, or create an artifact manifest. Omitted or empty `compile_targets` selects no package-compilation pass. When `full_vet` is omitted or false, the test command keeps its existing vet behavior. These settings are independent. +`validation.go_format` checks regular committed Go blobs with the Go formatter +used to build ReleaseKit. It includes vendor and nested-module source, ignores +symlink blobs and the worktree, and reports every selected path whose bytes +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. For example: ```json { "product": "Example", - "validation": {"go": { - "race": true, - "full_vet": true, - "compile_targets": ["linux/amd64", "linux/arm64"] - }} + "validation": { + "go": { + "race": true, + "full_vet": true, + "compile_targets": ["linux/amd64", "linux/arm64"] + }, + "go_format": true + } } ``` @@ -73,7 +82,9 @@ access disabled, adding `-race` when `validation.go.race` is true. When run native `go vet -mod=readonly ./...` after successful tests. They then run `go build -mod=readonly -o ./...` in static mode for each `validation.go.compile_targets` entry, in profile order. Compile failures use -the `package-compile` identity and include the selected target. Configured +the `package-compile` identity and include the selected target. When selected, +`validation.go_format` checks regular committed Go source with go/format after +package compilation; unformatted paths use the `go-format` identity. 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 034ae8d..076728b 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -36,6 +36,11 @@ complete target pair after tests and vet. Package compilation uses `CGO_ENABLED=0`, writes no artifact, and does not execute a target binary; its failures use the `package-compile` identity and include the target. The zero values retain the ordinary native test behavior and select no compilation. +Set `Validation.GoFormat` to check committed regular Go files using the Go +formatter built into ReleaseKit. It includes vendor and nested-module source, +ignores symlink blobs and worktree changes, and reports unformatted paths as +`go-format` without rewriting files. Its zero value disables the check; it runs +after tests, selected vet, and package compilation, before `AdditionalChecks`. 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 829dd72..71d1b45 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -48,6 +48,13 @@ pair after tests and vet. This uses `CGO_ENABLED=0`, produces no artifact, and does not execute target binaries. A failure is reported as `package-compile` with the selected target and prevents tag creation. +Set `Validation.GoFormat` to check committed regular Go source using the Go +formatter built into ReleaseKit. It includes vendor and nested modules, ignores +worktree changes and symlink blobs, and reports unformatted paths as +`go-format` without changing files. The default is disabled. The check follows +tests, selected vet, and package compilation, and a failure stops publication +before product checks or tag creation. + 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/validation.md b/docs/internal/validation.md index abf03f8..8c99636 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -18,9 +18,13 @@ executing a target binary. Test, vet, and package-compilation failures retain their `go-test`, `go-vet`, and `package-compile` identities, selected target where applicable, and underlying causes. Its request carries the captured repository directory and commit, output writer, -and internal settings. `Request.ReadCandidateTree` lets a -selected file check request candidate contents; the Go-only path does not -enumerate the tree. +and internal settings. When selected, the Go-format check loads the candidate +tree once, reads regular Go blobs from the captured commit, and compares them +with go/format output. It includes vendor and nested-module files, skips +symlink blobs, reports all unformatted paths, and stops with a contextual cause +on parse or inspection errors. It runs before product checks and build callbacks. +`Request.ReadCandidateTree` lets selected file checks request candidate +contents; the Go-only path does not enumerate the tree. `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes lazily, and resolves destinations without consulting the worktree or neighboring files. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f6241ef..61d8057 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -318,6 +318,11 @@ Existing endpoint-change and uncertain-push tests keep their ownership. ## Stage 5: Check committed Go formatting +**Completion:** Complete. `ValidationSpec.GoFormat` and profile +`validation.go_format` run `go/format` on regular Go blobs from the captured +commit. Findings identify unformatted paths; parser and inspection failures +retain their causes, and the check leaves repository contents unchanged. + **Outcome:** A declaration replaces tracked-file formatting loops. 1. Add `GoFormat` and `validation.go_format` with working executor integration. diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index 1167ac8..01a7881 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -78,6 +78,36 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) { } } +func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) { + repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") + writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n") + gitRun(t, repository, "add", "unformatted.go") + gitRun(t, repository, "commit", "-m", "add unformatted Go source") + productCalls := 0 + buildCalls := 0 + _, err := Check(context.Background(), Request{ + RepositoryDir: repository, + Product: "Example Product", + Version: testVersion, + Validation: validation.Settings{GoFormat: 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.GoFormat { + t.Fatalf("Check error = %v, want go-format failure", err) + } + if productCalls != 0 || buildCalls != 0 { + t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after formatting failure", productCalls, buildCalls) + } +} + func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") var output bytes.Buffer diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go index 30822fa..3203906 100644 --- a/internal/validation/diagnostic.go +++ b/internal/validation/diagnostic.go @@ -15,6 +15,8 @@ const ( GoVet Check = "go-vet" // PackageCompile identifies selected-target package compilation. PackageCompile Check = "package-compile" + // GoFormat identifies committed Go source formatting. + GoFormat Check = "go-format" ) // Finding describes a validation observation at an optional source location. diff --git a/internal/validation/format_test.go b/internal/validation/format_test.go new file mode 100644 index 0000000..899fb53 --- /dev/null +++ b/internal/validation/format_test.go @@ -0,0 +1,133 @@ +package validation + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" +) + +func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) { + repository := newTreeRepository(t) + writeTreeFile(t, repository, "main.go", []byte("package main\n\nfunc main() {}\n")) + writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\nfunc Value( ){return 1}\n")) + writeTreeFile(t, repository, "nested/module/odd name.go", []byte("package nested\nfunc Value( ){return 2}\n")) + writeTreeFile(t, repository, "notes.txt", []byte("not Go source\n")) + if err := os.Symlink("main.go", filepath.Join(repository, "link.go")); err != nil { + t.Fatal(err) + } + gitTree(t, repository, "add", ".") + gitTree(t, repository, "commit", "-m", "capture Go files") + commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")) + + // Worktree edits must not affect the formatting result for the captured commit. + writeTreeFile(t, repository, "main.go", []byte("package main\nfunc main( ){}\n")) + writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\n\nfunc Value() { return 1 }\n")) + worktreeMain, err := os.ReadFile(filepath.Join(repository, "main.go")) + if err != nil { + t.Fatal(err) + } + + tree, err := ReadCandidateTree(context.Background(), repository, commit) + if err != nil { + t.Fatal(err) + } + err = checkGoFormat(context.Background(), tree) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != GoFormat { + t.Fatalf("checkGoFormat error = %v, want go-format failure", err) + } + wantPaths := []string{"nested/module/odd name.go", "vendor/example/format.go"} + if len(failure.Findings) != len(wantPaths) { + t.Fatalf("format findings = %#v, want paths %q", failure.Findings, wantPaths) + } + for index, path := range wantPaths { + if failure.Findings[index].Path != path { + t.Errorf("format finding %d path = %q, want %q", index, failure.Findings[index].Path, path) + } + } + worktreeMainAfter, err := os.ReadFile(filepath.Join(repository, "main.go")) + if err != nil { + t.Fatal(err) + } + if string(worktreeMainAfter) != string(worktreeMain) { + t.Fatalf("format check changed worktree source: got %q, want %q", worktreeMainAfter, worktreeMain) + } +} + +func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) { + t.Run("parser", func(t *testing.T) { + repository := newTreeRepository(t) + writeTreeFile(t, repository, "broken.go", []byte("package broken\nfunc ( {\n")) + gitTree(t, repository, "add", ".") + gitTree(t, repository, "commit", "-m", "capture invalid Go source") + commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")) + tree, err := ReadCandidateTree(context.Background(), repository, commit) + if err != nil { + t.Fatal(err) + } + err = checkGoFormat(context.Background(), tree) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil { + t.Fatalf("parser failure = %#v, want go-format failure with cause", err) + } + if len(failure.Findings) != 1 || failure.Findings[0].Path != "broken.go" { + t.Fatalf("parser findings = %#v, want broken.go", failure.Findings) + } + if !strings.Contains(failure.Cause.Error(), "broken.go") { + t.Fatalf("parser cause = %v, want committed filename context", failure.Cause) + } + }) + + t.Run("inspection", func(t *testing.T) { + tree := &CandidateTree{ + repositoryDir: t.TempDir(), + entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}}, + byPath: map[string]int{"missing.go": 0}, + blobs: make(map[string][]byte), + } + err := checkGoFormat(context.Background(), tree) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil { + t.Fatalf("inspection failure = %#v, want go-format failure with cause", err) + } + if len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.go" { + t.Fatalf("inspection findings = %#v, want missing.go", failure.Findings) + } + }) +} + +func TestRunGoFormatIsOptionalAndStopsAfterFinding(t *testing.T) { + repository := newTreeRepository(t) + writeTreeFile(t, repository, "unformatted.go", []byte("package fixture\nfunc Value( ){return 1}\n")) + gitTree(t, repository, "add", ".") + gitTree(t, repository, "commit", "-m", "capture unformatted Go source") + commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")) + + logPath := installGoShim(t, "", false) + if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil { + t.Fatalf("default validation = %v, want success with formatting disabled", err) + } + if got := strings.Join(readGoShimLog(t, logPath), "\n"); !strings.Contains(got, "test -mod=readonly -count=1 ./...") { + t.Fatalf("default Go commands = %q, want standard suite", got) + } + + logPath = installGoShim(t, "", false) + err := Run(context.Background(), Request{ + RepositoryDir: repository, + Commit: commit, + Settings: Settings{GoFormat: true}, + }) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != GoFormat { + t.Fatalf("selected formatting error = %v, want go-format failure", err) + } + commands := strings.Join(readGoShimLog(t, logPath), "\n") + if strings.Contains(commands, "build ") || strings.Contains(commands, "vet ") { + t.Fatalf("later Go commands ran after formatting failure: %q", commands) + } +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 2eff571..32a6b96 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -2,8 +2,10 @@ package validation import ( + "bytes" "context" "fmt" + "go/format" "io" "os" "strings" @@ -16,6 +18,7 @@ type Settings struct { Race bool FullVet bool CompileTargets []Target + GoFormat bool } // Target selects one package-compilation target. @@ -88,5 +91,55 @@ func Run(ctx context.Context, request Request) error { } } } + if request.Settings.GoFormat { + tree, err := request.ReadCandidateTree(ctx) + if err != nil { + return &Failure{Check: GoFormat, Cause: err} + } + if err := checkGoFormat(ctx, tree); err != nil { + return err + } + } + return nil +} + +func checkGoFormat(ctx context.Context, tree *CandidateTree) error { + var findings []Finding + for _, entry := range tree.Entries() { + if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") { + continue + } + if err := ctx.Err(); err != nil { + return &Failure{Check: GoFormat, Cause: err} + } + contents, err := tree.ReadFile(ctx, entry.Path) + if err != nil { + return &Failure{ + Check: GoFormat, + Findings: []Finding{{ + Path: entry.Path, + Message: "unable to inspect committed Go source", + }}, + Cause: fmt.Errorf("inspect committed Go file %q: %w", entry.Path, err), + } + } + formatted, err := format.Source(contents) + if err != nil { + return &Failure{ + Check: GoFormat, + Findings: []Finding{{ + Path: entry.Path, + Message: "committed Go source cannot be formatted", + }}, + Cause: fmt.Errorf("format committed Go file %q: %w", entry.Path, err), + } + } + if !bytes.Equal(contents, formatted) { + findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"}) + } + } + if len(findings) != 0 { + return &Failure{Check: GoFormat, Findings: findings} + } return nil } diff --git a/validation_error.go b/validation_error.go index 8dd2eb9..6417ba2 100644 --- a/validation_error.go +++ b/validation_error.go @@ -8,7 +8,8 @@ import ( ) // ValidationCheck identifies a shared validation check that failed. -// Current checks include "go-test", "go-vet", and "package-compile". +// Current checks include "go-test", "go-vet", "package-compile", and +// "go-format". type ValidationCheck string // ValidationFinding describes a failed check observation at an optional diff --git a/validation_test.go b/validation_test.go index abeabf1..1557fb8 100644 --- a/validation_test.go +++ b/validation_test.go @@ -12,10 +12,10 @@ 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}, }) - if !request.Validation.Race || !request.Validation.FullVet { - t.Fatalf("candidate request validation = %#v, want race and full vet enabled", request.Validation) + if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat { + t.Fatalf("candidate request validation = %#v, want race, full vet, and formatting enabled", request.Validation) } want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}} if len(request.Validation.CompileTargets) != len(want) { @@ -39,3 +39,25 @@ func TestValidationErrorPreservesGoVetIdentity(t *testing.T) { t.Fatalf("converted error = %v, want cause preserved", got) } } + +func TestValidationErrorPreservesGoFormatIdentityAndFindings(t *testing.T) { + cause := errors.New("format parser failed") + got := convertValidationError(&validation.Failure{ + Check: validation.GoFormat, + Findings: []validation.Finding{{ + Path: "vendor/example/bad.go", + Message: "committed Go source cannot be formatted", + }}, + Cause: cause, + }) + var validationError *ValidationError + if !errors.As(got, &validationError) || validationError.Check != "go-format" { + t.Fatalf("converted error = %#v, want go-format validation identity", got) + } + if len(validationError.Findings) != 1 || validationError.Findings[0].Path != "vendor/example/bad.go" { + t.Fatalf("converted findings = %#v, want vendor path", validationError.Findings) + } + if !errors.Is(got, cause) { + t.Fatalf("converted error = %v, want parser cause preserved", got) + } +}