Inspect committed module replacements
This commit is contained in:
7
check.go
7
check.go
@@ -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
|
||||
@@ -122,6 +128,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
||||
GoFormat: request.Validation.GoFormat,
|
||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
|
||||
LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements,
|
||||
},
|
||||
},
|
||||
AdditionalChecks: candidateChecks(request.AdditionalChecks),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -164,6 +164,7 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
||||
GoFormat: settings.GoFormat,
|
||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||
},
|
||||
Go: releasekit.GoValidationSpec{
|
||||
Race: settings.Go.Race,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
docs/oss/licenses/BSD-3-Clause.txt
Normal file
27
docs/oss/licenses/BSD-3-Clause.txt
Normal 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.
|
||||
@@ -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
2
go.mod
@@ -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
2
go.sum
Normal 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=
|
||||
@@ -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.
|
||||
|
||||
92
internal/validation/local_replacements.go
Normal file
92
internal/validation/local_replacements.go
Normal 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
|
||||
}
|
||||
175
internal/validation/local_replacements_test.go
Normal file
175
internal/validation/local_replacements_test.go
Normal 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"))
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Settings struct {
|
||||
// ModuleDistributionSettings selects candidate distribution checks.
|
||||
type ModuleDistributionSettings struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user