Inspect committed module replacements

This commit is contained in:
2026-10-07 22:51:55 +00:00
parent 45dd827dda
commit eed95896f4
22 changed files with 424 additions and 16 deletions

View File

@@ -63,6 +63,9 @@ type ModuleDistributionValidationSpec struct {
// WorkspaceFiles rejects committed go.work and go.work.sum blobs, including
// nested paths and symlink entries. It does not select Go's test workspace.
WorkspaceFiles bool
// LocalReplacements rejects filesystem replacements in committed go.mod
// declarations, including nested modules. It does not select Go's workspace.
LocalReplacements bool
}
// GoValidationSpec selects checks performed by the Go toolchain.
@@ -93,6 +96,9 @@ type GoValidationSpec struct {
// 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.
// 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.
// 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
@@ -121,7 +127,8 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
GoFormat: request.Validation.GoFormat,
ModuleDistribution: validation.ModuleDistributionSettings{
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements,
},
},
AdditionalChecks: candidateChecks(request.AdditionalChecks),

View File

@@ -138,6 +138,9 @@ type ModuleDistributionSettings struct {
// WorkspaceFiles rejects committed go.work and go.work.sum blobs.
// Omitted or false disables the check.
WorkspaceFiles bool `json:"workspace_files,omitempty"`
// LocalReplacements rejects filesystem replacements in committed go.mod files.
// Omitted or false disables the check.
LocalReplacements bool `json:"local_replacements,omitempty"`
}
// GoValidationSettings selects checks performed by the Go toolchain.

View File

@@ -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},"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,"module_distribution":{"workspace_files":true,"local_replacements":true},"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.Go.Race || !settings.Validation.Go.FullVet {
if settings.Validation == nil || !settings.Validation.GoFormat || !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 got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want {

View File

@@ -163,7 +163,8 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
result := releasekit.ValidationSpec{
GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
},
Go: releasekit.GoValidationSpec{
Race: settings.Go.Race,

View File

@@ -119,7 +119,7 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
Validation: &cli.ValidationSettings{
GoFormat: true,
Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}},
ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true},
ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true, LocalReplacements: true},
},
}, nil)
if !request.Validation.Go.Race || !request.Validation.Go.FullVet {
@@ -128,8 +128,8 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
if !request.Validation.GoFormat {
t.Fatalf("candidate request GoFormat = false, want true")
}
if !request.Validation.ModuleDistribution.WorkspaceFiles {
t.Fatalf("candidate request workspace files = false, want true")
if !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements {
t.Fatalf("candidate request module distribution = %#v, want both checks enabled", request.Validation.ModuleDistribution)
}
if request.ExpectedPushURL != "" {
t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL)

View File

@@ -85,6 +85,13 @@ checks. The selected workspace-file check then reports committed go.work and
go.work.sum paths as `workspace-files`. Formatting failures are reported as
`go-format` with affected paths and preserve parser or source-inspection causes.
When `validation.module_distribution.local_replacements` is true, the command
parses committed `go.mod` declarations, including nested and vendor modules,
after workspace-file inspection. Filesystem replacements use the
`local-replacements` identity and include the declaration path, source line,
and target; versioned replacements are permitted. Committed module symlinks are
resolved within the candidate, regardless of `GOWORK`.
The optional profile field `expected_push_url` applies only to `publish`. A
nonempty value must exactly match the effective push URL resolved by Git;
mismatches fail during preflight before Go validation or tag creation. Git URL

View File

@@ -51,6 +51,14 @@ committed blob entries whose basename is `go.work` or `go.work.sum`, including
nested paths and symlinks. It checks candidate distribution contents regardless
of `GOWORK`; it does not select or change the workspace used by Go commands,
and it does not alter existing vendor behavior. Omitted or false disables it.
When `validation.module_distribution.local_replacements` is true, it parses all
committed `go.mod` declarations, including vendor and nested modules, and reports
`replace` directives whose replacement has no version. These filesystem
replacements include relative and absolute paths; versioned module replacements
are permitted. Committed `go.mod` symlinks are resolved within the candidate,
and unavailable selected contents or parse failures are reported as inspection
errors. This selection is independent of `workspace_files` and Go's `GOWORK`
setting. Omitted or false disables it.
For example:
```json
@@ -64,7 +72,8 @@ For example:
},
"go_format": true,
"module_distribution": {
"workspace_files": true
"workspace_files": true,
"local_replacements": true
}
}
}
@@ -97,6 +106,11 @@ package compilation; unformatted paths use the `go-format` identity. When
`validation.module_distribution.workspace_files` is true, shared validation
also reports committed go.work and go.work.sum blobs after Go formatting, using
the workspace-files identity. Uncommitted workspace files are not inspected.
When `validation.module_distribution.local_replacements` is true, shared
validation parses committed `go.mod` files after workspace inspection and reports
filesystem replacements as `local-replacements`, with path, directive line, and
target. Versioned replacements are permitted; parse and inspection failures
preserve their causes.
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.

View File

@@ -47,6 +47,13 @@ Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
examines the captured candidate even when `GOWORK=off`, ignores uncommitted
files, and does not change Go test workspace selection or vendor behavior. The
zero value disables the check; failures use `workspace-files`.
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
committed `go.mod` declarations throughout the candidate, including nested and
vendor modules. It reports filesystem `replace` directives with their source
path, line, and target as `local-replacements`; versioned replacements are
permitted. Committed module symlinks are resolved within the candidate. Parse
and inspection errors retain their causes, and the rule applies even when
`GOWORK=off`. Its zero value disables the check.
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

View File

@@ -61,6 +61,14 @@ The rule reads the captured candidate regardless of `GOWORK`; uncommitted files
are ignored, and Go workspace selection and vendor behavior are unchanged. Its
zero value disables the check. Findings use `workspace-files` and stop
publication before product checks or tag creation.
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
committed `go.mod` declarations across the candidate, including nested and
vendor modules. Filesystem `replace` directives are reported with their source
path, line, and target as `local-replacements`; versioned replacements are
permitted. Committed module symlinks are resolved within the candidate, and
parse or inspection errors retain their causes. This check is independent of
`GOWORK`; its zero value disables it, 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

View File

@@ -46,7 +46,7 @@ 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, structured check failures, and lazy committed-file access for captured candidates; `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, structured failures, and lazy committed-file access for captured candidates; `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. |

View File

@@ -30,6 +30,13 @@ The workspace-file check uses that same candidate inventory and reports blob
entries named go.work or go.work.sum, including nested paths and symlink blobs.
It does not read workspace contents or depend on the workspace selected for Go
commands.
The local-replacement check parses every committed go.mod blob with
`golang.org/x/mod/modfile`, including nested modules and vendor declarations.
It follows committed symlink declarations through the captured tree and fails
with an inspection cause when their selected contents are unavailable. A
replacement with no parsed version is reported with its go.mod path, line,
directive, and target; versioned replacements are allowed. It runs after the
workspace-file check using the same candidate inventory.
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
lazily, and resolves destinations without consulting the worktree or
neighboring files.

View File

@@ -1,5 +1,17 @@
# Open Source Attribution Notices
## Go Authors
Selected module: `golang.org/x/mod` at `v0.40.0`.
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) | [LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
## Standard Repository Policies
Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation,

View File

@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -359,6 +359,14 @@ rather than retesting Git enumeration mechanics.
## Stage 7: Select filesystem replacement inspection
**Completion:** Complete. `Validation.ModuleDistribution.LocalReplacements` and
`validation.module_distribution.local_replacements` parse committed `go.mod`
declarations, including nested/vendor files and committed symlink declarations.
Filesystem replacements report their declaration path, line, directive, and
target as `local-replacements`; versioned replacements are permitted. Parse and
inspection failures retain their causes, and this rule is independent of both
workspace-file selection and `GOWORK`.
**Outcome:** The second distribution rule parses actual module declarations.
1. Add `ModuleDistribution.LocalReplacements` and the equivalent profile field.

2
go.mod
View File

@@ -1,3 +1,5 @@
module gitea.maximumdirect.net/eric/ReleaseKit
go 1.25.0
require golang.org/x/mod v0.40.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=

View File

@@ -19,6 +19,8 @@ const (
GoFormat Check = "go-format"
// WorkspaceFiles identifies committed workspace files in module source.
WorkspaceFiles Check = "workspace-files"
// LocalReplacements identifies filesystem replacements in committed modules.
LocalReplacements Check = "local-replacements"
)
// Finding describes a validation observation at an optional source location.

View File

@@ -0,0 +1,92 @@
package validation
import (
"context"
"fmt"
"path"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"golang.org/x/mod/modfile"
)
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
var findings []Finding
for _, entry := range tree.Entries() {
if path.Base(entry.Path) != "go.mod" {
continue
}
contents, err := readModuleDeclaration(ctx, tree, entry)
if err != nil {
return &Failure{
Check: LocalReplacements,
Findings: []Finding{{Path: entry.Path, Message: "could not inspect committed module declaration"}},
Cause: fmt.Errorf("inspect committed %s: %w", entry.Path, err),
}
}
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
if err != nil {
return &Failure{
Check: LocalReplacements,
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
}
}
for _, replacement := range moduleFile.Replace {
if replacement.New.Version != "" {
continue
}
finding := Finding{
Path: entry.Path,
Message: "filesystem module replacement is committed",
Target: replacement.New.Path,
Directive: "replace",
}
if replacement.Syntax != nil {
finding.Line = replacement.Syntax.Start.Line
}
findings = append(findings, finding)
}
}
if len(findings) != 0 {
return &Failure{Check: LocalReplacements, Findings: findings}
}
return nil
}
func readModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) ([]byte, error) {
if entry.Type != "blob" {
return nil, fmt.Errorf("entry has type %q, not a blob", entry.Type)
}
if entry.Mode != "120000" {
if entry.Mode != "100644" && entry.Mode != "100755" {
return nil, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
}
contents, err := tree.ReadFile(ctx, entry.Path)
if err != nil {
return nil, fmt.Errorf("read module declaration: %w", err)
}
return contents, nil
}
target, err := tree.ReadFile(ctx, entry.Path)
if err != nil {
return nil, fmt.Errorf("read symlink declaration: %w", err)
}
destination, err := tree.ResolveDestination(ctx, entry.Path, string(target))
if err != nil {
return nil, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
}
if destination.Kind != DestinationEntry {
return nil, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
}
resolved, ok := tree.Entry(destination.Path)
if !ok || resolved.Type != "blob" || (resolved.Mode != "100644" && resolved.Mode != "100755") {
return nil, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
}
contents, err := tree.ReadFile(ctx, destination.Path)
if err != nil {
return nil, fmt.Errorf("read resolved module declaration %q: %w", destination.Path, err)
}
return contents, nil
}

View File

@@ -0,0 +1,175 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
repository := newTreeRepository(t)
absoluteTarget := filepath.Join(repository, "outside module")
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n\nreplace example.com/one => \"../local module\" // retained comment\n"))
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\nreplace (\n\texample.com/two => ../../local\n\texample.com/three => \""+absoluteTarget+"\"\n)\n"))
writeTreeFile(t, repository, "vendor/example/go.mod", []byte("module example.com/vendor\n\nreplace example.com/versioned v1.0.0 => example.com/fork v1.2.0\n"))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements {
t.Fatalf("checkLocalReplacements error = %v, want local-replacements failure", err)
}
want := map[string]Finding{
"go.mod": {Path: "go.mod", Line: 3, Target: "../local module", Directive: "replace"},
"nested/go.mod:4": {Path: "nested/go.mod", Line: 4, Target: "../../local", Directive: "replace"},
"nested/go.mod:5": {Path: "nested/go.mod", Line: 5, Target: absoluteTarget, Directive: "replace"},
}
if len(failure.Findings) != len(want) {
t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want))
}
for _, finding := range failure.Findings {
key := finding.Path
if finding.Path != "go.mod" {
key += ":" + strconv.Itoa(finding.Line)
}
expected, ok := want[key]
if !ok || finding.Line != expected.Line || finding.Target != expected.Target || finding.Directive != expected.Directive {
t.Errorf("finding = %#v, want %q", finding, key)
}
}
}
func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "modules/real.mod", []byte("module example.com/real\n\nreplace example.com/local => ../checkout\n"))
if err := os.MkdirAll(filepath.Join(repository, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../modules/real.mod", filepath.Join(repository, "nested/go.mod")); err != nil {
t.Fatal(err)
}
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || len(failure.Findings) != 1 {
t.Fatalf("checkLocalReplacements error = %v, want one symlink declaration finding", err)
}
finding := failure.Findings[0]
if finding.Path != "nested/go.mod" || finding.Line != 3 || finding.Target != "../checkout" {
t.Fatalf("symlink finding = %#v, want source path and resolved declaration", finding)
}
}
func TestCheckLocalReplacementsReportsUnavailableSymlinkContents(t *testing.T) {
repository := newTreeRepository(t)
if err := os.Symlink("missing.mod", filepath.Join(repository, "go.mod")); err != nil {
t.Fatal(err)
}
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
t.Fatalf("checkLocalReplacements error = %v, want local-replacements inspection failure", err)
}
if !strings.Contains(failure.Cause.Error(), "missing") || len(failure.Findings) != 1 || failure.Findings[0].Path != "go.mod" {
t.Fatalf("failure = %#v, want path and unavailable target cause", failure)
}
}
func TestCheckLocalReplacementsPreservesParseCause(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/bad\nreplace =>\n"))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || failure.Cause == nil || !strings.Contains(failure.Cause.Error(), "parse committed go.mod") {
t.Fatalf("checkLocalReplacements error = %v, want parse failure with cause", err)
}
if errors.Unwrap(failure) == nil || len(failure.Findings) != 1 || failure.Findings[0].Path != "go.mod" {
t.Fatalf("failure = %#v, want preserved parse cause and path", failure)
}
}
func TestRunSelectsDistributionChecksIndependentlyWithGoWorkspaceDisabled(t *testing.T) {
t.Setenv("GOWORK", "off")
for _, test := range []struct {
name string
workspaceFiles bool
localReplacements bool
wantCheck Check
}{
{name: "neither"},
{name: "workspace", workspaceFiles: true, wantCheck: WorkspaceFiles},
{name: "replacements", localReplacements: true, wantCheck: LocalReplacements},
{name: "both", workspaceFiles: true, localReplacements: true, wantCheck: WorkspaceFiles},
} {
t.Run(test.name, func(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\nreplace example.com/local => ../local\n"))
writeTreeFile(t, repository, "go.work", []byte("go 1.25.0\n"))
commit := commitCandidateTree(t, repository)
installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
WorkspaceFiles: test.workspaceFiles,
LocalReplacements: test.localReplacements,
}},
})
if test.wantCheck == "" {
if err != nil {
t.Fatalf("Run error = %v, want disabled checks", err)
}
return
}
failure, ok := err.(*Failure)
if !ok || failure.Check != test.wantCheck {
t.Fatalf("Run error = %v, want %s failure", err, test.wantCheck)
}
})
}
}
func TestRunAttributesCandidateTreeFailureToLocalReplacements(t *testing.T) {
repository := newTreeRepository(t)
installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: "missing-commit",
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
LocalReplacements: true,
}},
})
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
t.Fatalf("Run error = %v, want local-replacements candidate inspection failure", err)
}
}
func commitCandidateTree(t *testing.T, repository string) string {
t.Helper()
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture module declarations")
return strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
}

View File

@@ -25,7 +25,8 @@ type Settings struct {
// ModuleDistributionSettings selects candidate distribution checks.
type ModuleDistributionSettings struct {
WorkspaceFiles bool
WorkspaceFiles bool
LocalReplacements bool
}
// Target selects one package-compilation target.
@@ -98,10 +99,13 @@ func Run(ctx context.Context, request Request) error {
}
}
}
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles {
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements {
tree, err := request.ReadCandidateTree(ctx)
if err != nil {
check := WorkspaceFiles
check := LocalReplacements
if request.Settings.ModuleDistribution.WorkspaceFiles {
check = WorkspaceFiles
}
if request.Settings.GoFormat {
check = GoFormat
}
@@ -117,6 +121,11 @@ func Run(ctx context.Context, request Request) error {
return err
}
}
if request.Settings.ModuleDistribution.LocalReplacements {
if err := checkLocalReplacements(ctx, tree); err != nil {
return err
}
}
}
return nil
}

View File

@@ -9,7 +9,7 @@ import (
// ValidationCheck identifies a shared validation check that failed.
// Current checks include "go-test", "go-vet", "package-compile", "go-format",
// and "workspace-files".
// "workspace-files", and "local-replacements".
type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional

View File

@@ -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, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true}},
}, GoFormat: true, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}},
})
if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat || !request.Validation.ModuleDistribution.WorkspaceFiles {
t.Fatalf("candidate request validation = %#v, want race, full vet, formatting, and workspace inspection enabled", request.Validation)
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)
}
want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.CompileTargets) != len(want) {
@@ -28,6 +28,31 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
}
}
func TestValidationErrorPreservesLocalReplacementsIdentityAndFinding(t *testing.T) {
cause := errors.New("module declaration could not be read")
got := convertValidationError(&validation.Failure{
Check: validation.LocalReplacements,
Findings: []validation.Finding{{
Path: "nested/go.mod",
Line: 4,
Target: "../local",
Directive: "replace",
Message: "filesystem module replacement is committed",
}},
Cause: cause,
})
var validationError *ValidationError
if !errors.As(got, &validationError) || validationError.Check != "local-replacements" {
t.Fatalf("converted error = %#v, want local-replacements identity", got)
}
if len(validationError.Findings) != 1 || validationError.Findings[0].Path != "nested/go.mod" || validationError.Findings[0].Line != 4 || validationError.Findings[0].Target != "../local" || validationError.Findings[0].Directive != "replace" {
t.Fatalf("converted findings = %#v, want path, line, target, and directive", validationError.Findings)
}
if !errors.Is(got, cause) {
t.Fatalf("converted error = %v, want cause preserved", got)
}
}
func TestValidationErrorPreservesGoVetIdentity(t *testing.T) {
cause := errors.New("vet failed")
got := convertValidationError(&validation.Failure{Check: validation.GoVet, Cause: cause})