Inspect committed module replacements
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user