From 3a99536f9cab214e62a36ef91f62467a85d3704c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 02:25:12 +0000 Subject: [PATCH] Select committed validation inputs by module --- check.go | 14 ++- check_request_test.go | 38 ++++++ docs/roadmap/implementation.md | 8 ++ internal/candidate/check.go | 1 + internal/validation/diagnostic.go | 2 + internal/validation/tree.go | 156 +++++++++++++++++++++++ internal/validation/tree_test.go | 203 ++++++++++++++++++++++++++++++ internal/validation/validation.go | 15 ++- 8 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 check_request_test.go diff --git a/check.go b/check.go index be02dad..a0f3023 100644 --- a/check.go +++ b/check.go @@ -55,7 +55,11 @@ type ReleaseRequest struct { // ValidationSpec selects optional shared validation checks. Its zero value // preserves the current validation behavior. 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 // does not modify repository contents and is disabled when false. GoFormat bool @@ -176,6 +180,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request { Race: request.Validation.Go.Race, FullVet: request.Validation.Go.FullVet, CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), + SharedFiles: copyStringSlice(request.Validation.SharedFiles), GoFormat: request.Validation.GoFormat, Markdown: internalMarkdownSettings(request.Validation.Markdown), ModuleDistribution: validation.ModuleDistributionSettings{ @@ -194,6 +199,13 @@ func candidateRequest(request ReleaseRequest) candidate.Request { 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 { if spec == nil { return nil diff --git a/check_request_test.go b/check_request_test.go new file mode 100644 index 0000000..ff9eb83 --- /dev/null +++ b/check_request_test.go @@ -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") + } + } + }) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d298d01..8250dac 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 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 **Outcome:** Formatting, workspace, local replacement, file-size, and Markdown diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 6bc89d3..b7d09de 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -122,6 +122,7 @@ func Capture(ctx context.Context, request Request) (Candidate, error) { func Validate(ctx context.Context, request Request, captured Candidate) error { if err := validation.Run(ctx, validation.Request{ RepositoryDir: captured.RepositoryDir, + ModuleDir: captured.ModuleDir, Commit: captured.Commit, Output: request.Output, Settings: request.Validation, diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go index 7055065..bd6cd5f 100644 --- a/internal/validation/diagnostic.go +++ b/internal/validation/diagnostic.go @@ -25,6 +25,8 @@ const ( FileSize Check = "file-size" // MarkdownLinks identifies missing committed Markdown destinations or anchors. 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. diff --git a/internal/validation/tree.go b/internal/validation/tree.go index e69cf75..b871691 100644 --- a/internal/validation/tree.go +++ b/internal/validation/tree.go @@ -5,6 +5,7 @@ import ( "fmt" "path" "path/filepath" + "sort" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" @@ -16,6 +17,7 @@ type CandidateTree struct { context context.Context repositoryDir string entries []git.TreeEntry + inputEntries []git.TreeEntry byPath map[string]int directories map[string]struct{} blobReader *git.BlobReader @@ -25,9 +27,28 @@ type CandidateTree struct { 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 // worktree. The supplied context also governs the scoped reader through closure. 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) if err != nil { return nil, err @@ -47,6 +68,11 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand tree.directories[directory] = struct{}{} } } + tree.inputEntries, err = selectInputEntries(repositoryDir, entries, tree.byPath, scope) + if err != nil { + _ = tree.Close() + return nil, err + } return tree, nil } @@ -55,6 +81,136 @@ func (tree *CandidateTree) Entries() []git.TreeEntry { 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. // Regular-file content is not cached, so repeated reads request the object // again through the tree's scoped blob reader. diff --git a/internal/validation/tree_test.go b/internal/validation/tree_test.go index cb2a587..47e1414 100644 --- a/internal/validation/tree_test.go +++ b/internal/validation/tree_test.go @@ -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) { repository := newTreeRepository(t) writeTreeFile(t, repository, "source.txt", []byte("committed content")) diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 4ad600f..63891f4 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -4,6 +4,7 @@ package validation import ( "bytes" "context" + "errors" "fmt" "go/format" "io" @@ -19,6 +20,7 @@ type Settings struct { Race bool FullVet bool CompileTargets []Target + SharedFiles []string GoFormat bool Markdown *MarkdownSettings ModuleDistribution ModuleDistributionSettings @@ -51,6 +53,7 @@ type Target struct { // Request identifies the captured source and output used by shared validation. type Request struct { RepositoryDir string + ModuleDir string Commit string Output io.Writer Settings Settings @@ -59,7 +62,10 @@ type Request struct { // ReadCandidateTree loads the committed-file inventory when a selected check // needs file-oriented input. 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. @@ -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) if err != nil { + var failure *Failure + if errors.As(err, &failure) { + return failure + } check := MarkdownLinks if request.Settings.FileSize != nil { check = FileSize