Apply module scope to file validation checks

This commit is contained in:
2026-10-10 02:32:28 +00:00
parent 3a99536f9c
commit 02896b6e12
17 changed files with 308 additions and 79 deletions

View File

@@ -87,7 +87,7 @@ type FileSizeValidationSpec struct {
// LimitBytes is the maximum decimal byte count. Nil selects 1,000,000; // LimitBytes is the maximum decimal byte count. Nil selects 1,000,000;
// zero is a zero-byte limit, and negative values are invalid. // zero is a zero-byte limit, and negative values are invalid.
LimitBytes *int64 LimitBytes *int64
// Exclusions are literal repository-relative file or directory paths. // Exclusions are literal module-relative file or directory paths.
// A directory exclusion includes committed descendants. // A directory exclusion includes committed descendants.
Exclusions []string Exclusions []string
} }

View File

@@ -95,9 +95,10 @@ go.work.sum paths as `workspace-files`. Formatting failures are reported as
`go-format` with affected paths and preserve parser or source-inspection causes. `go-format` with affected paths and preserve parser or source-inspection causes.
When `validation.module_distribution.local_replacements` is true, the command When `validation.module_distribution.local_replacements` is true, the command
parses committed `go.mod` declarations, including nested and vendor modules, parses committed `go.mod` declarations in the selected module after
after workspace-file inspection. Filesystem replacements use the workspace-file inspection. Vendored contents remain in scope, while
`local-replacements` identity and include the declaration path, source line, independently released nested modules are excluded. Filesystem replacements use
the `local-replacements` identity and include the declaration path, source line,
and target; versioned replacements are permitted. Committed module symlinks are and target; versioned replacements are permitted. Committed module symlinks are
resolved within the candidate, regardless of `GOWORK`. resolved within the candidate, regardless of `GOWORK`.
When `validation.file_size` is a non-null object, the command checks each When `validation.file_size` is a non-null object, the command checks each

View File

@@ -69,12 +69,13 @@ Anchors keep Unicode letters and numbers, underscores, and hyphens; spaces
become hyphens, and repeated headings receive numeric suffixes. become hyphens, and repeated headings receive numeric suffixes.
When `validation.module_distribution.workspace_files` is true, it reports When `validation.module_distribution.workspace_files` is true, it reports
committed blob entries whose basename is `go.work` or `go.work.sum`, including committed `go.work` and `go.work.sum` files in the selected module inputs,
nested paths and symlinks. It checks candidate distribution contents regardless including nested paths and symlinks. It checks the captured candidate regardless
of `GOWORK`; it does not select or change the workspace used by Go commands, 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. and vendored contents remain in scope. Omitted or false disables it. When
When `validation.module_distribution.local_replacements` is true, it parses all `validation.module_distribution.local_replacements` is true, it parses the
committed `go.mod` declarations, including vendor and nested modules, and reports selected module's committed `go.mod` declarations. Vendored contents remain in
scope, while independently released nested modules are excluded. It reports
`replace` directives whose replacement has no version. These filesystem `replace` directives whose replacement has no version. These filesystem
replacements include relative and absolute paths; versioned module replacements replacements include relative and absolute paths; versioned module replacements
are permitted. Committed `go.mod` symlinks are resolved within the candidate, are permitted. Committed `go.mod` symlinks are resolved within the candidate,
@@ -109,8 +110,8 @@ check; `{}` enables it with a default of 1,000,000 bytes. A missing or null
`limit_bytes` selects that default, zero is a valid limit, and negative limits `limit_bytes` selects that default, zero is a valid limit, and negative limits
are invalid. are invalid.
`validation.file_size.exclusions` contains literal repository-relative file or `validation.file_size.exclusions` contains literal paths relative to the
directory paths. Directory exclusions include committed descendants, using path selected module. Directory exclusions include committed descendants, using path
boundaries (`assets` does not exclude `assets-old`). Ordinary relative path boundaries (`assets` does not exclude `assets-old`). Ordinary relative path
normalization applies; metacharacters are literal and unmatched paths have no normalization applies; metacharacters are literal and unmatched paths have no
effect. The check measures regular and symlink blobs, including small binaries; effect. The check measures regular and symlink blobs, including small binaries;

View File

@@ -43,22 +43,24 @@ ignores symlink blobs and worktree changes, and reports unformatted paths as
after tests, selected vet, and package compilation, before `AdditionalChecks`. after tests, selected vet, and package compilation, before `AdditionalChecks`.
Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
`go.work` and `go.work.sum` blobs, including nested and symlink entries. It `go.work` and `go.work.sum` files in the selected module inputs, including
examines the captured candidate even when `GOWORK=off`, ignores uncommitted nested and symlink entries. It examines the captured candidate even when
files, and does not change Go test workspace selection or vendor behavior. The `GOWORK=off`, ignores uncommitted files, and does not change Go test workspace
zero value disables the check; failures use `workspace-files`. selection; vendored contents remain in scope. The zero value disables the check;
failures use `workspace-files`.
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
committed `go.mod` declarations throughout the candidate, including nested and the selected module's committed `go.mod` declarations. Vendored contents remain
vendor modules. It reports filesystem `replace` directives with their source in scope, while independently released nested modules are excluded. It reports
path, line, and target as `local-replacements`; versioned replacements are 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 permitted. Committed module symlinks are resolved within the candidate. Parse
and inspection errors retain their causes, and the rule applies even when and inspection errors retain their causes, and the rule applies even when
`GOWORK=off`. Its zero value disables the check. `GOWORK=off`. Its zero value disables the check.
Set `Validation.FileSize` to check each committed blob against a byte limit. Set `Validation.FileSize` to check each committed blob against a byte limit.
Nil disables the check; an empty object enables the default of 1,000,000 Nil disables the check; an empty object enables the default of 1,000,000
decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects
negative values. `Exclusions` contains literal repository-relative files or negative values. `Exclusions` contains literal files or directories relative to
directories; excluded directories include descendants. Symlink blobs are the selected module; excluded directories include descendants. Symlink blobs are
measured by link-text bytes, and gitlinks are omitted. The check applies to the measured by link-text bytes, and gitlinks are omitted. The check applies to the
captured candidate and reports one oversized file with its measurement as captured candidate and reports one oversized file with its measurement as
`file-size`; it does not measure historical or aggregate size. `file-size`; it does not measure historical or aggregate size.

View File

@@ -62,9 +62,10 @@ are ignored, and Go workspace selection and vendor behavior are unchanged. Its
zero value disables the check. Findings use `workspace-files` and stop zero value disables the check. Findings use `workspace-files` and stop
publication before product checks or tag creation. publication before product checks or tag creation.
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
committed `go.mod` declarations across the candidate, including nested and selected module's committed `go.mod` declarations. Vendored contents remain in
vendor modules. Filesystem `replace` directives are reported with their source scope, while independently released nested modules are excluded. Filesystem
path, line, and target as `local-replacements`; versioned replacements are `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 permitted. Committed module symlinks are resolved within the candidate, and
parse or inspection errors retain their causes. This check is independent of parse or inspection errors retain their causes. This check is independent of
`GOWORK`; its zero value disables it, and a failure stops publication before `GOWORK`; its zero value disables it, and a failure stops publication before
@@ -72,9 +73,9 @@ product checks or tag creation.
Set `Validation.FileSize` to check each committed blob against a byte limit. Set `Validation.FileSize` to check each committed blob against a byte limit.
Nil disables the check; an empty object enables the default of 1,000,000 Nil disables the check; an empty object enables the default of 1,000,000
decimal bytes. `LimitBytes` uses strict greater-than, accepts zero, and rejects decimal bytes. `LimitBytes` uses strict greater-than, accepts zero, and rejects
negative values. `Exclusions` contains literal repository-relative files or negative values. `Exclusions` contains literal files or directories relative to
directories, including committed descendants of excluded directories. Symlink the selected module, including committed descendants of excluded directories.
blob size is its link text; gitlinks are omitted. This check measures only the Symlink blob size is its link text; gitlinks are omitted. This check measures only the
captured candidate and reports one oversized file with its measurement as captured candidate and reports one oversized file with its measurement as
`file-size`; it stops publication before product checks or tag creation. `file-size`; it stops publication before product checks or tag creation.

View File

@@ -332,6 +332,15 @@ at different paths keep distinct relative-link findings. Confirm selection
errors with content checks disabled and preserve cancellation/cleanup regressions. errors with content checks disabled and preserve cancellation/cleanup regressions.
Run affected validation and facade tests. Run affected validation and facade tests.
**Completed:** Formatting, workspace, local-replacement, file-size, and Markdown
checks now iterate the selected committed inputs. Targeted reads still resolve
against the full repository tree. File-size exclusions are translated from the
selected module to repository-relative paths, and shared-file failures preserve
their public check, path, and cause. Updated file-size guidance and verified
in-scope and out-of-scope findings, shared inputs, cross-module anchors, reader
cleanup, facade conversion, `go test ./internal/validation .`, and
`go run ./internal/apicheck`.
## Stage 6: Execute Go validation in the module with reusable local settings ## Stage 6: Execute Go validation in the module with reusable local settings
**Outcome:** One selected standard suite runs in the module, and later composite **Outcome:** One selected standard suite runs in the module, and later composite

View File

@@ -12,9 +12,8 @@ func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
if settings.LimitBytes != nil { if settings.LimitBytes != nil {
limit = *settings.LimitBytes limit = *settings.LimitBytes
} }
exclusions := normalizedExclusions(settings.Exclusions) for _, entry := range tree.inputEntries {
for _, entry := range tree.Entries() { if entry.Type != "blob" || !entry.HasSize || excludedByPath(entry.Path, settings.Exclusions) {
if entry.Type != "blob" || !entry.HasSize || excludedByPath(entry.Path, exclusions) {
continue continue
} }
if entry.Size > limit { if entry.Size > limit {
@@ -31,6 +30,23 @@ func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
return nil return nil
} }
func fileSizeSettingsForModule(settings *FileSizeSettings, modulePath string) *FileSizeSettings {
if settings == nil {
return nil
}
selected := *settings
moduleRelative := make([]string, len(settings.Exclusions))
for index, exclusion := range settings.Exclusions {
if modulePath == "." || path.IsAbs(exclusion) || exclusion == "" {
moduleRelative[index] = exclusion
continue
}
moduleRelative[index] = modulePath + "/" + exclusion
}
selected.Exclusions = normalizedExclusions(moduleRelative)
return &selected
}
func normalizedExclusions(exclusions []string) []string { func normalizedExclusions(exclusions []string) []string {
result := make([]string, 0, len(exclusions)) result := make([]string, 0, len(exclusions))
for _, exclusion := range exclusions { for _, exclusion := range exclusions {

View File

@@ -29,9 +29,10 @@ func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{{ entries := []git.TreeEntry{{
Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true, Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true,
}}} }}
tree := &CandidateTree{entries: entries, inputEntries: entries}
err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit}) err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit})
if !test.wantError { if !test.wantError {
if err != nil { if err != nil {
@@ -104,10 +105,11 @@ func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
err := checkFileSize(&CandidateTree{entries: test.entries}, &FileSizeSettings{ settings := fileSizeSettingsForModule(&FileSizeSettings{
LimitBytes: int64Pointer(10), LimitBytes: int64Pointer(10),
Exclusions: test.exclusions, Exclusions: test.exclusions,
}) }, ".")
err := checkFileSize(&CandidateTree{entries: test.entries, inputEntries: test.entries}, settings)
failure, ok := err.(*Failure) failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != test.wantPath { if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != test.wantPath {
t.Fatalf("checkFileSize() error = %v, want only %q to exceed the limit", err, test.wantPath) t.Fatalf("checkFileSize() error = %v, want only %q to exceed the limit", err, test.wantPath)

View File

@@ -89,6 +89,7 @@ func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
tree := &CandidateTree{ tree := &CandidateTree{
repositoryDir: t.TempDir(), repositoryDir: t.TempDir(),
entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}}, entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
inputEntries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
byPath: map[string]int{"missing.go": 0}, byPath: map[string]int{"missing.go": 0},
} }
cleanupCandidateTree(t, tree) cleanupCandidateTree(t, tree)

View File

@@ -13,7 +13,7 @@ import (
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error { func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
var findings []Finding var findings []Finding
replacementsByObjectID := make(map[string][]moduleReplacementSummary) replacementsByObjectID := make(map[string][]moduleReplacementSummary)
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return &Failure{Check: LocalReplacements, Findings: findings, Cause: err} return &Failure{Check: LocalReplacements, Findings: findings, Cause: err}
} }

View File

@@ -29,9 +29,7 @@ func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
t.Fatalf("checkLocalReplacements error = %v, want local-replacements failure", err) t.Fatalf("checkLocalReplacements error = %v, want local-replacements failure", err)
} }
want := map[string]Finding{ want := map[string]Finding{
"go.mod": {Path: "go.mod", Line: 3, Target: "../local module", Directive: "replace"}, "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) { if len(failure.Findings) != len(want) {
t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want)) t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want))
@@ -46,6 +44,27 @@ func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
t.Errorf("finding = %#v, want %q", finding, key) t.Errorf("finding = %#v, want %q", finding, key)
} }
} }
nestedTree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: filepath.Join(repository, "nested")})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, nestedTree)
err = checkLocalReplacements(context.Background(), nestedTree)
if !errors.As(err, &failure) || failure.Check != LocalReplacements || len(failure.Findings) != 2 {
t.Fatalf("nested replacement findings = %#v, want the selected module's two replacements", err)
}
for _, finding := range failure.Findings {
wantLine := 4
wantTarget := "../../local"
if finding.Line == 5 {
wantLine = 5
wantTarget = absoluteTarget
}
if finding.Path != "nested/go.mod" || finding.Line != wantLine || finding.Target != wantTarget || finding.Directive != "replace" {
t.Errorf("nested finding = %#v, want line %d target %q", finding, wantLine, wantTarget)
}
}
} }
func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) { func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
@@ -58,7 +77,7 @@ func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
commit := commitCandidateTree(t, repository) commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit) tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: filepath.Join(repository, "nested")})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -91,7 +110,10 @@ func TestCheckLocalReplacementsSharesSummariesAcrossModuleSymlinks(t *testing.T)
} }
} }
commit := commitCandidateTree(t, repository) commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit) tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: repository,
SharedFiles: []string{"first/go.mod", "second/go.mod"},
})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -17,7 +17,7 @@ func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckou
} }
cache := newMarkdownCache(tree) cache := newMarkdownCache(tree)
var findings []Finding var findings []Finding
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err} return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
} }

View File

@@ -0,0 +1,125 @@
package validation
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
func TestRunFileChecksUseSelectedInputsAndFullReferenceLookup(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n\ngo 1.25.0\nreplace example.com/rootdep => ./missing\n"))
writeTreeFile(t, repository, "go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "outside.go", []byte("package outside\nfunc broken( ){}\n"))
writeTreeFile(t, repository, "outside.bin", []byte(strings.Repeat("x", 160)))
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "nested/source.go", []byte("package nested\n\nfunc ready() {}\n"))
writeTreeFile(t, repository, "nested/ignored.bin", []byte(strings.Repeat("x", 160)))
writeTreeFile(t, repository, "nested/docs/guide.md", []byte("# Guide\n\n[details](../../sibling/docs/target.md#details)\n"))
writeTreeFile(t, repository, "nested/child/go.mod", []byte("module example.com/nested/child\n"))
writeTreeFile(t, repository, "nested/child/broken.go", []byte("package child\nfunc broken( ){}\n"))
writeTreeFile(t, repository, "nested/child/go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "sibling/go.mod", []byte("module example.com/sibling\n\nreplace example.com/siblingdep => ./missing\n"))
writeTreeFile(t, repository, "sibling/go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "sibling/broken.go", []byte("package sibling\nfunc broken( ){}\n"))
writeTreeFile(t, repository, "sibling/outside.bin", []byte(strings.Repeat("x", 160)))
writeTreeFile(t, repository, "sibling/docs/target.md", []byte("# Details\n\n[broken](missing.md)\n"))
writeTreeFile(t, repository, "shared/broken.go", []byte("package shared\nfunc broken( ){}\n"))
commit := commitCandidateTree(t, repository)
installGoShim(t, "", false)
moduleDir := filepath.Join(repository, "nested")
settings := Settings{
GoFormat: true,
ModuleDistribution: ModuleDistributionSettings{
WorkspaceFiles: true,
LocalReplacements: true,
},
FileSize: &FileSizeSettings{LimitBytes: int64Pointer(100), Exclusions: []string{"ignored.bin"}},
Markdown: &MarkdownSettings{EnforceCheckoutBoundary: true},
}
request := Request{RepositoryDir: repository, ModuleDir: moduleDir, Commit: commit, Settings: settings}
if err := Run(context.Background(), request); err != nil {
t.Fatalf("selected module checks = %v, want out-of-scope violations ignored and cross-module anchor resolved", err)
}
request.Settings.FileSize.Exclusions = nil
err := Run(context.Background(), request)
assertScopedFailure(t, err, FileSize, "nested/ignored.bin")
writeTreeFile(t, repository, "nested/broken.go", []byte("package nested\nfunc broken( ){}\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{GoFormat: true}
assertScopedFailure(t, Run(context.Background(), request), GoFormat, "nested/broken.go")
writeTreeFile(t, repository, "nested/go.work", []byte("go 1.25.0\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{ModuleDistribution: ModuleDistributionSettings{WorkspaceFiles: true}}
assertScopedFailure(t, Run(context.Background(), request), WorkspaceFiles, "nested/go.work")
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\ngo 1.25.0\nreplace example.com/nesteddep => ./missing\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{ModuleDistribution: ModuleDistributionSettings{LocalReplacements: true}}
assertScopedFailure(t, Run(context.Background(), request), LocalReplacements, "nested/go.mod")
writeTreeFile(t, repository, "nested/docs/guide.md", []byte("# Guide\n\n[details](../../sibling/docs/target.md#details)\n\n[missing](absent.md)\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{Markdown: &MarkdownSettings{}}
assertScopedFailure(t, Run(context.Background(), request), MarkdownLinks, "nested/docs/guide.md")
request.Settings = Settings{GoFormat: true, SharedFiles: []string{"shared/broken.go"}}
assertScopedFailure(t, Run(context.Background(), request), GoFormat, "shared/broken.go")
}
func assertScopedFailure(t *testing.T, err error, check Check, path string) {
t.Helper()
failure, ok := err.(*Failure)
if !ok || failure.Check != check {
t.Fatalf("validation error = %v, want %s failure", err, check)
}
for _, finding := range failure.Findings {
if finding.Path == path {
return
}
}
t.Fatalf("%s findings = %#v, want path %q", check, failure.Findings, path)
}
func TestFileSizeExclusionsAreModuleRelative(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
writeTreeFile(t, repository, "nested/dist/output.bin", []byte(strings.Repeat("x", 20)))
writeTreeFile(t, repository, "nested/dist-old/output.bin", []byte(strings.Repeat("x", 20)))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: filepath.Join(repository, "nested")})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
settings := fileSizeSettingsForModule(&FileSizeSettings{LimitBytes: int64Pointer(10), Exclusions: []string{"./dist/"}}, tree.modulePath)
err = checkFileSize(tree, settings)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "nested/dist-old/output.bin" {
t.Fatalf("module-relative file-size exclusion error = %v, want only nested/dist-old/output.bin", err)
}
}
func TestSharedFileSelectionFailureCarriesCauseAndPresentation(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
commit := commitCandidateTree(t, repository)
installGoShim(t, "", false)
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 || failure.Cause == nil || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.txt" {
t.Fatalf("shared-file selection error = %#v, want path, cause, and shared-files identity", err)
}
if !strings.Contains(err.Error(), "shared-files") || !strings.Contains(err.Error(), "missing.txt") {
t.Fatalf("shared-file error text = %q, want existing check/path presentation", err)
}
}

View File

@@ -16,6 +16,7 @@ import (
type CandidateTree struct { type CandidateTree struct {
context context.Context context context.Context
repositoryDir string repositoryDir string
modulePath string
entries []git.TreeEntry entries []git.TreeEntry
inputEntries []git.TreeEntry inputEntries []git.TreeEntry
byPath map[string]int byPath map[string]int
@@ -53,9 +54,14 @@ func ReadCandidateTreeForScope(ctx context.Context, repositoryDir, commit string
if err != nil { if err != nil {
return nil, err return nil, err
} }
modulePath, err := repositoryModulePath(repositoryDir, scope.ModuleDir)
if err != nil {
return nil, err
}
tree := &CandidateTree{ tree := &CandidateTree{
context: ctx, context: ctx,
repositoryDir: repositoryDir, repositoryDir: repositoryDir,
modulePath: modulePath,
entries: entries, entries: entries,
byPath: make(map[string]int, len(entries)), byPath: make(map[string]int, len(entries)),
directories: make(map[string]struct{}), directories: make(map[string]struct{}),
@@ -68,7 +74,7 @@ func ReadCandidateTreeForScope(ctx context.Context, repositoryDir, commit string
tree.directories[directory] = struct{}{} tree.directories[directory] = struct{}{}
} }
} }
tree.inputEntries, err = selectInputEntries(repositoryDir, entries, tree.byPath, scope) tree.inputEntries, err = selectInputEntries(entries, tree.byPath, modulePath, scope.SharedFiles)
if err != nil { if err != nil {
_ = tree.Close() _ = tree.Close()
return nil, err return nil, err
@@ -88,34 +94,7 @@ func (tree *CandidateTree) InputEntries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.inputEntries...) return append([]git.TreeEntry(nil), tree.inputEntries...)
} }
func selectInputEntries(repositoryDir string, entries []git.TreeEntry, byPath map[string]int, scope TreeScope) ([]git.TreeEntry, error) { func selectInputEntries(entries []git.TreeEntry, byPath map[string]int, modulePath string, sharedFiles []string) ([]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) boundaries := make([]string, 0)
for _, entry := range entries { for _, entry := range entries {
moduleRelative, ok := relativeToModule(entry.Path, modulePath) moduleRelative, ok := relativeToModule(entry.Path, modulePath)
@@ -145,7 +124,6 @@ func selectInputEntries(repositoryDir string, entries []git.TreeEntry, byPath ma
} }
} }
sharedFiles := scope.SharedFiles
usingDefaults := sharedFiles == nil usingDefaults := sharedFiles == nil
if usingDefaults { if usingDefaults {
sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"} sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}
@@ -153,14 +131,16 @@ func selectInputEntries(repositoryDir string, entries []git.TreeEntry, byPath ma
for _, requested := range sharedFiles { for _, requested := range sharedFiles {
name, err := normalizeSharedFile(requested) name, err := normalizeSharedFile(requested)
if err != nil { if err != nil {
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: err.Error()}}} cause := fmt.Errorf("invalid shared file path %q: %w", requested, err)
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: cause.Error()}}, Cause: cause}
} }
index, exists := byPath[name] index, exists := byPath[name]
if !exists || entries[index].Type != "blob" { if !exists || entries[index].Type != "blob" {
if usingDefaults { if usingDefaults {
continue continue
} }
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}} cause := fmt.Errorf("selected shared file %q is not present in the committed tree", name)
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}, Cause: cause}
} }
selected[name] = entries[index] selected[name] = entries[index]
} }
@@ -177,6 +157,35 @@ func selectInputEntries(repositoryDir string, entries []git.TreeEntry, byPath ma
return inputs, nil return inputs, nil
} }
func repositoryModulePath(repositoryDir, moduleDir string) (string, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return "", fmt.Errorf("resolve candidate repository directory: %w", err)
}
if moduleDir == "" {
moduleDir = repositoryAbs
}
if !filepath.IsAbs(moduleDir) {
return "", fmt.Errorf("selected module directory must be absolute: %q", moduleDir)
}
moduleAbs, err := filepath.Abs(moduleDir)
if err != nil {
return "", fmt.Errorf("resolve selected module directory: %w", err)
}
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
if err != nil {
return "", fmt.Errorf("make selected module relative to repository: %w", err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir)
}
modulePath := filepath.ToSlash(relative)
if relative == "." {
modulePath = "."
}
return modulePath, nil
}
func relativeToModule(repositoryPath, modulePath string) (string, bool) { func relativeToModule(repositoryPath, modulePath string) (string, bool) {
if modulePath == "." { if modulePath == "." {
return repositoryPath, true return repositoryPath, true

View File

@@ -148,6 +148,7 @@ func Run(ctx context.Context, request Request) (result error) {
return &Failure{Check: check, Cause: err} return &Failure{Check: check, Cause: err}
} }
lastContentCheck := Check("") lastContentCheck := Check("")
fileSizeSettings := fileSizeSettingsForModule(request.Settings.FileSize, tree.modulePath)
defer func() { defer func() {
if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" { if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" {
result = &Failure{ result = &Failure{
@@ -180,7 +181,7 @@ func Run(ctx context.Context, request Request) (result error) {
} }
} }
if request.Settings.FileSize != nil { if request.Settings.FileSize != nil {
if err := checkFileSize(tree, request.Settings.FileSize); err != nil { if err := checkFileSize(tree, fileSizeSettings); err != nil {
return err return err
} }
} }
@@ -199,7 +200,7 @@ func Run(ctx context.Context, request Request) (result error) {
func checkWorkspaceFiles(tree *CandidateTree) error { func checkWorkspaceFiles(tree *CandidateTree) error {
var findings []Finding var findings []Finding
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if entry.Type != "blob" { if entry.Type != "blob" {
continue continue
} }
@@ -220,7 +221,7 @@ func checkWorkspaceFiles(tree *CandidateTree) error {
func checkGoFormat(ctx context.Context, tree *CandidateTree) error { func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
var findings []Finding var findings []Finding
formattedByObjectID := make(map[string]bool) formattedByObjectID := make(map[string]bool)
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") { if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") {
continue continue
} }

View File

@@ -9,13 +9,14 @@ import (
) )
func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) { func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{ entries := []git.TreeEntry{
{Path: "go.work", Type: "blob", Mode: "100644"}, {Path: "go.work", Type: "blob", Mode: "100644"},
{Path: "nested/go.work.sum", Type: "blob", Mode: "100644"}, {Path: "nested/go.work.sum", Type: "blob", Mode: "100644"},
{Path: "vendor/nested/link/go.work", Type: "blob", Mode: "120000"}, {Path: "vendor/nested/link/go.work", Type: "blob", Mode: "120000"},
{Path: "nested/not-go.work.backup", Type: "blob", Mode: "100644"}, {Path: "nested/not-go.work.backup", Type: "blob", Mode: "100644"},
{Path: "submodule/go.work", Type: "commit", Mode: "160000"}, {Path: "submodule/go.work", Type: "commit", Mode: "160000"},
}} }
tree := &CandidateTree{entries: entries, inputEntries: entries}
err := checkWorkspaceFiles(tree) err := checkWorkspaceFiles(tree)
failure, ok := err.(*Failure) failure, ok := err.(*Failure)
if !ok || failure.Check != WorkspaceFiles { if !ok || failure.Check != WorkspaceFiles {
@@ -43,10 +44,11 @@ func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
} }
func TestCheckWorkspaceFilesAllowsOtherNames(t *testing.T) { func TestCheckWorkspaceFilesAllowsOtherNames(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{ entries := []git.TreeEntry{
{Path: "go.mod", Type: "blob", Mode: "100644"}, {Path: "go.mod", Type: "blob", Mode: "100644"},
{Path: "examples/go.work.example", Type: "blob", Mode: "100644"}, {Path: "examples/go.work.example", Type: "blob", Mode: "100644"},
}} }
tree := &CandidateTree{entries: entries, inputEntries: entries}
if err := checkWorkspaceFiles(tree); err != nil { if err := checkWorkspaceFiles(tree); err != nil {
t.Fatalf("checkWorkspaceFiles error = %v, want no findings", err) t.Fatalf("checkWorkspaceFiles error = %v, want no findings", err)
} }

37
validation_scope_test.go Normal file
View File

@@ -0,0 +1,37 @@
package releasekit
import (
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
)
func TestSharedFileFailureKeepsPublicIdentityPathAndCause(t *testing.T) {
cause := errors.New("selected file is missing")
err := convertValidationError(&validation.Failure{
Check: validation.SharedFiles,
Findings: []validation.Finding{{
Path: "shared/NOTICE.md",
Message: "selected shared file is not present in the committed tree",
}},
Cause: cause,
})
var failure *ValidationError
if !errors.As(err, &failure) {
t.Fatalf("converted error = %T, want *ValidationError", err)
}
if failure.Check != ValidationCheck("shared-files") {
t.Fatalf("validation check = %q, want shared-files", failure.Check)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "shared/NOTICE.md" {
t.Fatalf("validation findings = %#v, want selected shared path", failure.Findings)
}
if !errors.Is(err, cause) {
t.Fatalf("converted error %v does not preserve its cause", err)
}
if !strings.Contains(err.Error(), "shared-files") || !strings.Contains(err.Error(), "shared/NOTICE.md") {
t.Fatalf("validation presentation = %q, want check and path", err)
}
}