Select committed validation inputs by module
This commit is contained in:
14
check.go
14
check.go
@@ -55,7 +55,11 @@ type ReleaseRequest struct {
|
|||||||
// ValidationSpec selects optional shared validation checks. Its zero value
|
// ValidationSpec selects optional shared validation checks. Its zero value
|
||||||
// preserves the current validation behavior.
|
// preserves the current validation behavior.
|
||||||
type ValidationSpec struct {
|
type ValidationSpec struct {
|
||||||
Go GoValidationSpec
|
// SharedFiles adds repository-relative committed files to optional file
|
||||||
|
// checks. Nil selects the optional defaults; an empty non-nil slice selects
|
||||||
|
// none. Explicit files are checked for presence even when file checks are off.
|
||||||
|
SharedFiles []string
|
||||||
|
Go GoValidationSpec
|
||||||
// GoFormat checks the formatting of committed regular Go source files. It
|
// GoFormat checks the formatting of committed regular Go source files. It
|
||||||
// does not modify repository contents and is disabled when false.
|
// does not modify repository contents and is disabled when false.
|
||||||
GoFormat bool
|
GoFormat bool
|
||||||
@@ -176,6 +180,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)),
|
||||||
|
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||||
GoFormat: request.Validation.GoFormat,
|
GoFormat: request.Validation.GoFormat,
|
||||||
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||||
@@ -194,6 +199,13 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyStringSlice(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append(make([]string, 0, len(values)), values...)
|
||||||
|
}
|
||||||
|
|
||||||
func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings {
|
func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings {
|
||||||
if spec == nil {
|
if spec == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
38
check_request_test.go
Normal file
38
check_request_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package releasekit
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCandidateRequestPreservesSharedFileSelection(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
wantNil bool
|
||||||
|
wantValues []string
|
||||||
|
}{
|
||||||
|
{name: "omitted defaults", input: nil, wantNil: true},
|
||||||
|
{name: "explicit empty", input: []string{}, wantValues: []string{}},
|
||||||
|
{name: "explicit paths", input: []string{"LICENSE", "docs/shared.md"}, wantValues: []string{"LICENSE", "docs/shared.md"}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
request := ReleaseRequest{Validation: ValidationSpec{SharedFiles: tt.input}}
|
||||||
|
mapped := candidateRequest(request).Validation.SharedFiles
|
||||||
|
if (mapped == nil) != tt.wantNil {
|
||||||
|
t.Fatalf("mapped shared files nil=%t, want %t", mapped == nil, tt.wantNil)
|
||||||
|
}
|
||||||
|
if len(mapped) != len(tt.wantValues) {
|
||||||
|
t.Fatalf("mapped shared files = %#v, want %#v", mapped, tt.wantValues)
|
||||||
|
}
|
||||||
|
for index, value := range tt.wantValues {
|
||||||
|
if mapped[index] != value {
|
||||||
|
t.Fatalf("mapped shared files = %#v, want %#v", mapped, tt.wantValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tt.input) != 0 {
|
||||||
|
request.Validation.SharedFiles[0] = "changed"
|
||||||
|
if mapped[0] != tt.wantValues[0] {
|
||||||
|
t.Fatal("mapped shared-file selection aliases the caller's slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -289,6 +289,14 @@ Use committed files rather than worktree presence. Show that full lookup still
|
|||||||
resolves an excluded module's destination. Metadata-only selection starts no
|
resolves an excluded module's destination. Metadata-only selection starts no
|
||||||
batch content reader. Run validation tree/scope tests.
|
batch content reader. Run validation tree/scope tests.
|
||||||
|
|
||||||
|
**Completed:** Added a sorted, deduplicated committed input view for the selected
|
||||||
|
module and explicit shared files while retaining full-tree reference lookup.
|
||||||
|
Nested module boundaries exclude their descendants, vendored modules remain
|
||||||
|
included, and shared-file defaults, replacement, empty selection, and missing
|
||||||
|
committed files follow the roadmap. Verified root and nested scopes, worktree-only
|
||||||
|
file rejection, cross-module references, lazy content reads, `go test ./internal/validation ./internal/candidate .`,
|
||||||
|
and `go run ./internal/apicheck`.
|
||||||
|
|
||||||
## Stage 5: Apply selected inputs to every optional file check
|
## Stage 5: Apply selected inputs to every optional file check
|
||||||
|
|
||||||
**Outcome:** Formatting, workspace, local replacement, file-size, and Markdown
|
**Outcome:** Formatting, workspace, local replacement, file-size, and Markdown
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
|
|||||||
func Validate(ctx context.Context, request Request, captured Candidate) error {
|
func Validate(ctx context.Context, request Request, captured Candidate) error {
|
||||||
if err := validation.Run(ctx, validation.Request{
|
if err := validation.Run(ctx, validation.Request{
|
||||||
RepositoryDir: captured.RepositoryDir,
|
RepositoryDir: captured.RepositoryDir,
|
||||||
|
ModuleDir: captured.ModuleDir,
|
||||||
Commit: captured.Commit,
|
Commit: captured.Commit,
|
||||||
Output: request.Output,
|
Output: request.Output,
|
||||||
Settings: request.Validation,
|
Settings: request.Validation,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const (
|
|||||||
FileSize Check = "file-size"
|
FileSize Check = "file-size"
|
||||||
// MarkdownLinks identifies missing committed Markdown destinations or anchors.
|
// MarkdownLinks identifies missing committed Markdown destinations or anchors.
|
||||||
MarkdownLinks Check = "markdown-links"
|
MarkdownLinks Check = "markdown-links"
|
||||||
|
// SharedFiles identifies a requested shared file that is unavailable.
|
||||||
|
SharedFiles Check = "shared-files"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finding describes a validation observation at an optional source location.
|
// Finding describes a validation observation at an optional source location.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
@@ -16,6 +17,7 @@ type CandidateTree struct {
|
|||||||
context context.Context
|
context context.Context
|
||||||
repositoryDir string
|
repositoryDir string
|
||||||
entries []git.TreeEntry
|
entries []git.TreeEntry
|
||||||
|
inputEntries []git.TreeEntry
|
||||||
byPath map[string]int
|
byPath map[string]int
|
||||||
directories map[string]struct{}
|
directories map[string]struct{}
|
||||||
blobReader *git.BlobReader
|
blobReader *git.BlobReader
|
||||||
@@ -25,9 +27,28 @@ type CandidateTree struct {
|
|||||||
closeErr error
|
closeErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TreeScope selects the automatic input files for one physical module and
|
||||||
|
// optional repository-shared files. ModuleDir is an absolute path. A nil
|
||||||
|
// SharedFiles slice selects the optional default files; a non-nil slice
|
||||||
|
// replaces those defaults.
|
||||||
|
type TreeScope struct {
|
||||||
|
ModuleDir string
|
||||||
|
SharedFiles []string
|
||||||
|
}
|
||||||
|
|
||||||
// ReadCandidateTree enumerates the captured commit without reading from the
|
// ReadCandidateTree enumerates the captured commit without reading from the
|
||||||
// worktree. The supplied context also governs the scoped reader through closure.
|
// worktree. The supplied context also governs the scoped reader through closure.
|
||||||
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
|
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
|
||||||
|
repositoryAbs, err := filepath.Abs(repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
|
||||||
|
}
|
||||||
|
return ReadCandidateTreeForScope(ctx, repositoryDir, commit, TreeScope{ModuleDir: repositoryAbs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCandidateTreeForScope retains the complete committed tree for reference
|
||||||
|
// lookup and separately constructs the selected automatic input paths.
|
||||||
|
func ReadCandidateTreeForScope(ctx context.Context, repositoryDir, commit string, scope TreeScope) (*CandidateTree, error) {
|
||||||
entries, err := git.ReadTree(ctx, repositoryDir, commit)
|
entries, err := git.ReadTree(ctx, repositoryDir, commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -47,6 +68,11 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand
|
|||||||
tree.directories[directory] = struct{}{}
|
tree.directories[directory] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tree.inputEntries, err = selectInputEntries(repositoryDir, entries, tree.byPath, scope)
|
||||||
|
if err != nil {
|
||||||
|
_ = tree.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return tree, nil
|
return tree, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +81,136 @@ func (tree *CandidateTree) Entries() []git.TreeEntry {
|
|||||||
return append([]git.TreeEntry(nil), tree.entries...)
|
return append([]git.TreeEntry(nil), tree.entries...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InputEntries returns the ordered committed files selected for automatic
|
||||||
|
// checks. It is separate from Entries so reference resolution retains the full
|
||||||
|
// repository tree.
|
||||||
|
func (tree *CandidateTree) InputEntries() []git.TreeEntry {
|
||||||
|
return append([]git.TreeEntry(nil), tree.inputEntries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectInputEntries(repositoryDir string, entries []git.TreeEntry, byPath map[string]int, scope TreeScope) ([]git.TreeEntry, error) {
|
||||||
|
repositoryAbs, err := filepath.Abs(repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
|
||||||
|
}
|
||||||
|
moduleDir := scope.ModuleDir
|
||||||
|
if moduleDir == "" {
|
||||||
|
moduleDir = repositoryAbs
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(moduleDir) {
|
||||||
|
return nil, fmt.Errorf("selected module directory must be absolute: %q", moduleDir)
|
||||||
|
}
|
||||||
|
moduleAbs, err := filepath.Abs(moduleDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve selected module directory: %w", err)
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("make selected module relative to repository: %w", err)
|
||||||
|
}
|
||||||
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||||
|
return nil, fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir)
|
||||||
|
}
|
||||||
|
modulePath := filepath.ToSlash(relative)
|
||||||
|
if relative == "." {
|
||||||
|
modulePath = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
boundaries := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
|
||||||
|
if !ok || moduleRelative == "go.mod" || path.Base(moduleRelative) != "go.mod" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if insideVendor(moduleRelative) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boundaries = append(boundaries, path.Dir(entry.Path))
|
||||||
|
}
|
||||||
|
selected := make(map[string]git.TreeEntry)
|
||||||
|
for _, entry := range entries {
|
||||||
|
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
|
||||||
|
if !ok || moduleRelative == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
excluded := false
|
||||||
|
for _, boundary := range boundaries {
|
||||||
|
if entry.Path == boundary || strings.HasPrefix(entry.Path, boundary+"/") {
|
||||||
|
excluded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !excluded {
|
||||||
|
selected[entry.Path] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedFiles := scope.SharedFiles
|
||||||
|
usingDefaults := sharedFiles == nil
|
||||||
|
if usingDefaults {
|
||||||
|
sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}
|
||||||
|
}
|
||||||
|
for _, requested := range sharedFiles {
|
||||||
|
name, err := normalizeSharedFile(requested)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: err.Error()}}}
|
||||||
|
}
|
||||||
|
index, exists := byPath[name]
|
||||||
|
if !exists || entries[index].Type != "blob" {
|
||||||
|
if usingDefaults {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}}
|
||||||
|
}
|
||||||
|
selected[name] = entries[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(selected))
|
||||||
|
for name := range selected {
|
||||||
|
paths = append(paths, name)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
inputs := make([]git.TreeEntry, 0, len(paths))
|
||||||
|
for _, name := range paths {
|
||||||
|
inputs = append(inputs, selected[name])
|
||||||
|
}
|
||||||
|
return inputs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeToModule(repositoryPath, modulePath string) (string, bool) {
|
||||||
|
if modulePath == "." {
|
||||||
|
return repositoryPath, true
|
||||||
|
}
|
||||||
|
if repositoryPath == modulePath {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
prefix := modulePath + "/"
|
||||||
|
if !strings.HasPrefix(repositoryPath, prefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(repositoryPath, prefix), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func insideVendor(moduleRelative string) bool {
|
||||||
|
for _, component := range strings.Split(moduleRelative, "/") {
|
||||||
|
if component == "vendor" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSharedFile(name string) (string, error) {
|
||||||
|
if name == "" || path.IsAbs(name) || strings.Contains(name, "\\") {
|
||||||
|
return "", fmt.Errorf("shared file path must be a repository-relative file path")
|
||||||
|
}
|
||||||
|
clean := path.Clean(name)
|
||||||
|
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||||
|
return "", fmt.Errorf("shared file path must stay within the repository")
|
||||||
|
}
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ReadFile returns newly allocated, caller-owned bytes for a committed blob.
|
// ReadFile returns newly allocated, caller-owned bytes for a committed blob.
|
||||||
// Regular-file content is not cached, so repeated reads request the object
|
// Regular-file content is not cached, so repeated reads request the object
|
||||||
// again through the tree's scoped blob reader.
|
// again through the tree's scoped blob reader.
|
||||||
|
|||||||
@@ -78,6 +78,209 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadCandidateTreeForScopeExcludesNestedModulesAndRetainsFullLookup(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "COPYRIGHT", []byte("copyright\n"))
|
||||||
|
writeTreeFile(t, repository, "LICENSE", []byte("license\n"))
|
||||||
|
writeTreeFile(t, repository, "docs/oss/attribution.md", []byte("attribution\n"))
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
||||||
|
writeTreeFile(t, repository, "root.go", []byte("package root\n"))
|
||||||
|
writeTreeFile(t, repository, "parent/go.mod", []byte("module example.com/parent\n"))
|
||||||
|
writeTreeFile(t, repository, "parent/main.go", []byte("package parent\n"))
|
||||||
|
writeTreeFile(t, repository, "parent/vendor/library/go.mod", []byte("not a boundary\n"))
|
||||||
|
writeTreeFile(t, repository, "parent/vendor/library/source.go", []byte("package library\n"))
|
||||||
|
writeTreeFile(t, repository, "parent/nested/go.mod", []byte("module example.com/nested\n"))
|
||||||
|
writeTreeFile(t, repository, "parent/nested/source.go", []byte("package nested\n"))
|
||||||
|
writeTreeFile(t, repository, "sibling/go.mod", []byte("module example.com/sibling\n"))
|
||||||
|
writeTreeFile(t, repository, "sibling/source.go", []byte("package sibling\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
writeTreeFile(t, repository, "parent/worktree-only.go", []byte("package parent\n"))
|
||||||
|
|
||||||
|
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
||||||
|
ModuleDir: filepath.Join(repository, "parent"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupCandidateTree(t, tree)
|
||||||
|
var got []string
|
||||||
|
for _, entry := range tree.InputEntries() {
|
||||||
|
got = append(got, entry.Path)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"COPYRIGHT", "LICENSE", "docs/oss/attribution.md", "parent/go.mod", "parent/main.go",
|
||||||
|
"parent/vendor/library/go.mod", "parent/vendor/library/source.go",
|
||||||
|
}
|
||||||
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("selected inputs = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := tree.Entry("parent/nested/source.go"); !ok {
|
||||||
|
t.Fatal("full tree lookup lost an excluded nested module destination")
|
||||||
|
}
|
||||||
|
destination, err := tree.ResolveDestination(context.Background(), "parent/main.go", "../sibling/source.go")
|
||||||
|
if err != nil || destination.Kind != DestinationEntry || destination.Path != "sibling/source.go" {
|
||||||
|
t.Fatalf("excluded sibling destination = %#v, %v", destination, err)
|
||||||
|
}
|
||||||
|
nestedDestination, err := tree.ResolveDestination(context.Background(), "parent/main.go", "nested/source.go")
|
||||||
|
if err != nil || nestedDestination.Kind != DestinationEntry || nestedDestination.Path != "parent/nested/source.go" {
|
||||||
|
t.Fatalf("excluded nested-module destination = %#v, %v", nestedDestination, err)
|
||||||
|
}
|
||||||
|
if tree.blobReader != nil || tree.blobReads != 0 {
|
||||||
|
t.Fatalf("metadata-only selection read blob content: reader=%v reads=%d", tree.blobReader != nil, tree.blobReads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCandidateTreeForRootScopeExcludesNestedModules(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
||||||
|
writeTreeFile(t, repository, "root.go", []byte("package root\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/source.go", []byte("package nested\n"))
|
||||||
|
writeTreeFile(t, repository, "vendor/library/go.mod", []byte("not a boundary\n"))
|
||||||
|
writeTreeFile(t, repository, "vendor/library/source.go", []byte("package library\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
|
||||||
|
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: repository})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupCandidateTree(t, tree)
|
||||||
|
want := []string{"go.mod", "root.go", "vendor/library/go.mod", "vendor/library/source.go"}
|
||||||
|
if got := inputPaths(tree); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("root module inputs = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCandidateTreeSharedFilesReplaceDefaultsAndDeduplicate(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "LICENSE", []byte("default\n"))
|
||||||
|
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
|
||||||
|
writeTreeFile(t, repository, "module/source.go", []byte("package module\n"))
|
||||||
|
writeTreeFile(t, repository, "sibling/shared.txt", []byte("selected explicitly\n"))
|
||||||
|
writeTreeFile(t, repository, "literal/*.md", []byte("literal wildcard path\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
moduleDir := filepath.Join(repository, "module")
|
||||||
|
|
||||||
|
selected, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
SharedFiles: []string{"sibling/shared.txt", "module/source.go", "sibling/shared.txt"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupCandidateTree(t, selected)
|
||||||
|
var got []string
|
||||||
|
for _, entry := range selected.InputEntries() {
|
||||||
|
got = append(got, entry.Path)
|
||||||
|
}
|
||||||
|
want := []string{"module/go.mod", "module/source.go", "sibling/shared.txt"}
|
||||||
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("explicit shared inputs = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
SharedFiles: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupCandidateTree(t, empty)
|
||||||
|
if got := inputPaths(empty); strings.Join(got, "\n") != "module/go.mod\nmodule/source.go" {
|
||||||
|
t.Fatalf("explicit empty input set = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
literal, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
SharedFiles: []string{"literal/*.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupCandidateTree(t, literal)
|
||||||
|
if got := inputPaths(literal); strings.Join(got, "\n") != "literal/*.md\nmodule/go.mod\nmodule/source.go" {
|
||||||
|
t.Fatalf("literal wildcard path inputs = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCandidateTreeDefaultsAreOptionalAndExplicitMissingFilesFail(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
|
||||||
|
writeTreeFile(t, repository, "module/source.go", []byte("package module\n"))
|
||||||
|
writeTreeFile(t, repository, "LICENSE", []byte("license\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
moduleDir := filepath.Join(repository, "module")
|
||||||
|
|
||||||
|
defaults, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: moduleDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleanupCandidateTree(t, defaults)
|
||||||
|
if got := inputPaths(defaults); strings.Join(got, "\n") != "LICENSE\nmodule/go.mod\nmodule/source.go" {
|
||||||
|
t.Fatalf("default shared inputs = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTreeFile(t, repository, "missing.md", []byte("present only in the worktree\n"))
|
||||||
|
_, err = ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
||||||
|
ModuleDir: moduleDir,
|
||||||
|
SharedFiles: []string{"missing.md"},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != SharedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.md" {
|
||||||
|
t.Fatalf("missing explicit shared file error = %#v, want path-specific shared-files failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBuildsInputsOnlyForChecksOrExplicitSharedFiles(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/validation-tree\n\ngo 1.25.0\n"))
|
||||||
|
writeTreeFile(t, repository, "main.go", []byte("package validationtree\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
gitPath, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(t.TempDir(), "bin")
|
||||||
|
if err := os.MkdirAll(bin, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(t.TempDir(), "tree-reads")
|
||||||
|
wrapper := "#!/bin/sh\nREAL_GIT='" + gitPath + "'\nLOG='" + logPath + "'\nfor ARG in \"$@\"; do [ \"$ARG\" = ls-tree ] && echo read >> \"$LOG\"; done\nexec \"$REAL_GIT\" \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
|
||||||
|
t.Fatalf("metadata-only Run(): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("default-only request constructed file inputs: stat error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Run(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Commit: commit,
|
||||||
|
Settings: Settings{SharedFiles: []string{"missing.txt"}},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != SharedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.txt" {
|
||||||
|
t.Fatalf("explicit missing shared file Run() error = %#v", err)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(logPath); err != nil || len(data) == 0 {
|
||||||
|
t.Fatalf("explicit shared-file request did not construct committed file inputs: %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputPaths(tree *CandidateTree) []string {
|
||||||
|
entries := tree.InputEntries()
|
||||||
|
paths := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
paths = append(paths, entry.Path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) {
|
func TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) {
|
||||||
repository := newTreeRepository(t)
|
repository := newTreeRepository(t)
|
||||||
writeTreeFile(t, repository, "source.txt", []byte("committed content"))
|
writeTreeFile(t, repository, "source.txt", []byte("committed content"))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package validation
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/format"
|
"go/format"
|
||||||
"io"
|
"io"
|
||||||
@@ -19,6 +20,7 @@ type Settings struct {
|
|||||||
Race bool
|
Race bool
|
||||||
FullVet bool
|
FullVet bool
|
||||||
CompileTargets []Target
|
CompileTargets []Target
|
||||||
|
SharedFiles []string
|
||||||
GoFormat bool
|
GoFormat bool
|
||||||
Markdown *MarkdownSettings
|
Markdown *MarkdownSettings
|
||||||
ModuleDistribution ModuleDistributionSettings
|
ModuleDistribution ModuleDistributionSettings
|
||||||
@@ -51,6 +53,7 @@ type Target struct {
|
|||||||
// Request identifies the captured source and output used by shared validation.
|
// Request identifies the captured source and output used by shared validation.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
RepositoryDir string
|
RepositoryDir string
|
||||||
|
ModuleDir string
|
||||||
Commit string
|
Commit string
|
||||||
Output io.Writer
|
Output io.Writer
|
||||||
Settings Settings
|
Settings Settings
|
||||||
@@ -59,7 +62,10 @@ type Request struct {
|
|||||||
// ReadCandidateTree loads the committed-file inventory when a selected check
|
// ReadCandidateTree loads the committed-file inventory when a selected check
|
||||||
// needs file-oriented input.
|
// needs file-oriented input.
|
||||||
func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) {
|
func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) {
|
||||||
return ReadCandidateTree(ctx, request.RepositoryDir, request.Commit)
|
return ReadCandidateTreeForScope(ctx, request.RepositoryDir, request.Commit, TreeScope{
|
||||||
|
ModuleDir: request.ModuleDir,
|
||||||
|
SharedFiles: request.Settings.SharedFiles,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes shared validation for the captured candidate.
|
// Run executes shared validation for the captured candidate.
|
||||||
@@ -118,9 +124,14 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil {
|
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
||||||
|
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection {
|
||||||
tree, err := request.ReadCandidateTree(ctx)
|
tree, err := request.ReadCandidateTree(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var failure *Failure
|
||||||
|
if errors.As(err, &failure) {
|
||||||
|
return failure
|
||||||
|
}
|
||||||
check := MarkdownLinks
|
check := MarkdownLinks
|
||||||
if request.Settings.FileSize != nil {
|
if request.Settings.FileSize != nil {
|
||||||
check = FileSize
|
check = FileSize
|
||||||
|
|||||||
Reference in New Issue
Block a user