Check committed Go formatting

This commit is contained in:
2026-10-07 22:39:15 +00:00
parent 368043984e
commit c5f925c949
17 changed files with 318 additions and 22 deletions

View File

@@ -49,6 +49,9 @@ type ReleaseRequest struct {
// preserves the current validation behavior. // preserves the current validation behavior.
type ValidationSpec struct { type ValidationSpec struct {
Go GoValidationSpec 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. // 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 // 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 // each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
// compilation produces no artifacts and does not execute target binaries. // 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 // Build selects targets, Check builds them after validation in temporary
// storage outside the worktree, then rechecks // storage outside the worktree, then rechecks
// branch, HEAD, worktree, index, and local tag state. Success establishes local // 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, Race: request.Validation.Go.Race,
FullVet: request.Validation.Go.FullVet, FullVet: request.Validation.Go.FullVet,
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
GoFormat: request.Validation.GoFormat,
}, },
AdditionalChecks: candidateChecks(request.AdditionalChecks), AdditionalChecks: candidateChecks(request.AdditionalChecks),
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),

View File

@@ -127,6 +127,9 @@ type Settings struct {
// ValidationSettings selects shared candidate checks in a release profile. // ValidationSettings selects shared candidate checks in a release profile.
type ValidationSettings struct { type ValidationSettings struct {
Go GoValidationSettings `json:"go,omitempty"` 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. // GoValidationSettings selects checks performed by the Go toolchain.

View File

@@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
func TestLoadSettingsReadsGoValidation(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) {
repoDir := t.TempDir() repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json") 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) t.Fatal(err)
} }
@@ -152,8 +152,8 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if settings.Validation == nil || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet {
t.Fatalf("validation settings = %#v, want Go race and full vet enabled", settings.Validation) 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 { 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) t.Fatalf("compile targets = %q, want %q", got, want)

View File

@@ -161,6 +161,7 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
return releasekit.ValidationSpec{} return releasekit.ValidationSpec{}
} }
result := releasekit.ValidationSpec{ result := releasekit.ValidationSpec{
GoFormat: settings.GoFormat,
Go: releasekit.GoValidationSpec{ Go: releasekit.GoValidationSpec{
Race: settings.Go.Race, Race: settings.Go.Race,
FullVet: settings.Go.FullVet, FullVet: settings.Go.FullVet,

View File

@@ -117,12 +117,16 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
Product: "Example Product", Product: "Example Product",
ExpectedPushURL: "publication-only value", ExpectedPushURL: "publication-only value",
Validation: &cli.ValidationSettings{ 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) }, nil)
if !request.Validation.Go.Race || !request.Validation.Go.FullVet { if !request.Validation.Go.Race || !request.Validation.Go.FullVet {
t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation) 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 != "" { if request.ExpectedPushURL != "" {
t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL) t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL)
} }

View File

@@ -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 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 ./...` `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 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 configured additional checks in profile order. It does not inspect remote
availability or publish a tag. 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 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 mode with cgo disabled. This check produces no artifacts and does not execute
target binaries. Compilation failures use the `package-compile` identity and 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. setting runs after shared validation and configured checks.
`check` writes errors and diagnostics to standard error. It returns status 0 `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` 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, 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 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 tests. Selected package compilation and committed Go formatting follow, then
local tag and pushes only its exact tag object. It prints the confirmed version, 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 candidate commit, and tag object
on success. Validation on success. Validation
progress and failed publication state, including the captured publication 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 When `validation.go.compile_targets` is selected, package compilation runs
after tests and full vet and before configured checks. Each failure is reported 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 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 The optional profile field `expected_push_url` applies only to `publish`. A
nonempty value must exactly match the effective push URL resolved by Git; nonempty value must exactly match the effective push URL resolved by Git;

View File

@@ -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` executable, or create an artifact manifest. Omitted or empty `compile_targets`
selects no package-compilation pass. When `full_vet` is omitted or false, the selects no package-compilation pass. When `full_vet` is omitted or false, the
test command keeps its existing vet behavior. These settings are independent. 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: For example:
```json ```json
{ {
"product": "Example", "product": "Example",
"validation": {"go": { "validation": {
"race": true, "go": {
"full_vet": true, "race": true,
"compile_targets": ["linux/amd64", "linux/arm64"] "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 run native `go vet -mod=readonly ./...` after successful tests. They then run
`go build -mod=readonly -o <os.DevNull> ./...` in static mode for each `go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
`validation.go.compile_targets` entry, in profile order. Compile failures use `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` commands run afterward, once each in order, as additional product
checks. Artifact builds selected by the separate `build` setting run after checks. Artifact builds selected by the separate `build` setting run after
shared validation and product checks. shared validation and product checks.

View File

@@ -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 `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 failures use the `package-compile` identity and include the target. The zero
values retain the ordinary native test behavior and select no compilation. 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. ReleaseKit calls `AdditionalChecks` once after shared validation when supplied.
It does not download a different toolchain automatically. If race prerequisites It does not download a different toolchain automatically. If race prerequisites
are unavailable, the `go-test` check fails rather than being skipped; a selected are unavailable, the `go-test` check fails rather than being skipped; a selected

View File

@@ -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` does not execute target binaries. A failure is reported as `package-compile`
with the selected target and prevents tag creation. 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 If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the and additional checks, before creating the tag. The operation then rechecks the
candidate and remote guards, creates an annotated tag for the captured commit, candidate and remote guards, creates an annotated tag for the captured commit,

View File

@@ -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 their `go-test`, `go-vet`, and `package-compile` identities, selected target
where applicable, and underlying causes. Its where applicable, and underlying causes. Its
request carries the captured repository directory and commit, output writer, request carries the captured repository directory and commit, output writer,
and internal settings. `Request.ReadCandidateTree` lets a and internal settings. When selected, the Go-format check loads the candidate
selected file check request candidate contents; the Go-only path does not tree once, reads regular Go blobs from the captured commit, and compares them
enumerate the tree. 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 `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
lazily, and resolves destinations without consulting the worktree or lazily, and resolves destinations without consulting the worktree or
neighboring files. neighboring files.

View File

@@ -318,6 +318,11 @@ Existing endpoint-change and uncertain-push tests keep their ownership.
## Stage 5: Check committed Go formatting ## 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. **Outcome:** A declaration replaces tracked-file formatting loops.
1. Add `GoFormat` and `validation.go_format` with working executor integration. 1. Add `GoFormat` and `validation.go_format` with working executor integration.

View File

@@ -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) { func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer var output bytes.Buffer

View File

@@ -15,6 +15,8 @@ const (
GoVet Check = "go-vet" GoVet Check = "go-vet"
// PackageCompile identifies selected-target package compilation. // PackageCompile identifies selected-target package compilation.
PackageCompile Check = "package-compile" PackageCompile Check = "package-compile"
// GoFormat identifies committed Go source formatting.
GoFormat Check = "go-format"
) )
// Finding describes a validation observation at an optional source location. // Finding describes a validation observation at an optional source location.

View File

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

View File

@@ -2,8 +2,10 @@
package validation package validation
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"go/format"
"io" "io"
"os" "os"
"strings" "strings"
@@ -16,6 +18,7 @@ type Settings struct {
Race bool Race bool
FullVet bool FullVet bool
CompileTargets []Target CompileTargets []Target
GoFormat bool
} }
// Target selects one package-compilation target. // 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 return nil
} }

View File

@@ -8,7 +8,8 @@ import (
) )
// ValidationCheck identifies a shared validation check that failed. // 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 type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional // ValidationFinding describes a failed check observation at an optional

View File

@@ -12,10 +12,10 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
Validation: ValidationSpec{Go: GoValidationSpec{ Validation: ValidationSpec{Go: GoValidationSpec{
Race: true, FullVet: true, Race: true, FullVet: true,
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
}}, }, GoFormat: true},
}) })
if !request.Validation.Race || !request.Validation.FullVet { if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat {
t.Fatalf("candidate request validation = %#v, want race and full vet enabled", request.Validation) 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"}} want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.CompileTargets) != len(want) { 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) 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)
}
}