Reuse parsed validation content by blob
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"golang.org/x/mod/modfile"
|
||||
@@ -11,12 +12,16 @@ import (
|
||||
|
||||
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
var findings []Finding
|
||||
replacementsByObjectID := make(map[string][]moduleReplacementSummary)
|
||||
for _, entry := range tree.Entries() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: LocalReplacements, Findings: findings, Cause: err}
|
||||
}
|
||||
if path.Base(entry.Path) != "go.mod" {
|
||||
continue
|
||||
}
|
||||
|
||||
contents, err := readModuleDeclaration(ctx, tree, entry)
|
||||
declaration, err := resolveModuleDeclaration(ctx, tree, entry)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
@@ -24,26 +29,49 @@ func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
Cause: fmt.Errorf("inspect committed %s: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
|
||||
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
|
||||
replacements, ok := replacementsByObjectID[declaration.ObjectID]
|
||||
if !ok {
|
||||
contents, err := tree.ReadFile(ctx, declaration.Path)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not inspect committed module declaration"}},
|
||||
Cause: fmt.Errorf("inspect committed %s: read module declaration: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
|
||||
if err != nil {
|
||||
return &Failure{
|
||||
Check: LocalReplacements,
|
||||
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
|
||||
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
|
||||
}
|
||||
}
|
||||
replacements = make([]moduleReplacementSummary, 0, len(moduleFile.Replace))
|
||||
for _, replacement := range moduleFile.Replace {
|
||||
if replacement.New.Version != "" {
|
||||
continue
|
||||
}
|
||||
line := 0
|
||||
if replacement.Syntax != nil {
|
||||
line = replacement.Syntax.Start.Line
|
||||
}
|
||||
replacements = append(replacements, moduleReplacementSummary{
|
||||
target: strings.Clone(replacement.New.Path),
|
||||
line: line,
|
||||
})
|
||||
}
|
||||
contents = nil
|
||||
moduleFile = nil
|
||||
replacementsByObjectID[declaration.ObjectID] = replacements
|
||||
}
|
||||
for _, replacement := range moduleFile.Replace {
|
||||
if replacement.New.Version != "" {
|
||||
continue
|
||||
}
|
||||
for _, replacement := range replacements {
|
||||
finding := Finding{
|
||||
Path: entry.Path,
|
||||
Message: "filesystem module replacement is committed",
|
||||
Target: replacement.New.Path,
|
||||
Target: replacement.target,
|
||||
Directive: "replace",
|
||||
}
|
||||
if replacement.Syntax != nil {
|
||||
finding.Line = replacement.Syntax.Start.Line
|
||||
Line: replacement.line,
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
@@ -54,39 +82,36 @@ func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) ([]byte, error) {
|
||||
type moduleReplacementSummary struct {
|
||||
target string
|
||||
line int
|
||||
}
|
||||
|
||||
func resolveModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) (git.TreeEntry, error) {
|
||||
if entry.Type != "blob" {
|
||||
return nil, fmt.Errorf("entry has type %q, not a blob", entry.Type)
|
||||
return git.TreeEntry{}, fmt.Errorf("entry has type %q, not a blob", entry.Type)
|
||||
}
|
||||
if entry.Mode != "120000" {
|
||||
if entry.Mode != "100644" && entry.Mode != "100755" {
|
||||
return nil, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
|
||||
return git.TreeEntry{}, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
|
||||
}
|
||||
contents, err := tree.ReadFile(ctx, entry.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read module declaration: %w", err)
|
||||
}
|
||||
return contents, nil
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
target, err := tree.readSymlinkTarget(ctx, entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read symlink declaration: %w", err)
|
||||
return git.TreeEntry{}, fmt.Errorf("read symlink declaration: %w", err)
|
||||
}
|
||||
destination, err := tree.ResolveDestination(ctx, entry.Path, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
|
||||
return git.TreeEntry{}, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
|
||||
}
|
||||
if destination.Kind != DestinationEntry {
|
||||
return nil, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
|
||||
return git.TreeEntry{}, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
|
||||
}
|
||||
resolved, ok := tree.Entry(destination.Path)
|
||||
if !ok || resolved.Type != "blob" || (resolved.Mode != "100644" && resolved.Mode != "100755") {
|
||||
return nil, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
|
||||
return git.TreeEntry{}, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
|
||||
}
|
||||
contents, err := tree.ReadFile(ctx, destination.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read resolved module declaration %q: %w", destination.Path, err)
|
||||
}
|
||||
return contents, nil
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
@@ -74,6 +74,57 @@ func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLocalReplacementsSharesSummariesAcrossModuleSymlinks(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "shared/module.mod", []byte(
|
||||
"module shared.example/module\n"+
|
||||
"go 1.25.0\n"+
|
||||
"replace example.org/one => ../one\n"+
|
||||
"replace example.org/two => ../two\n",
|
||||
))
|
||||
for _, directory := range []string{"first", "second"} {
|
||||
if err := os.MkdirAll(filepath.Join(repository, directory), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../shared/module.mod", filepath.Join(repository, directory, "go.mod")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
commit := commitCandidateTree(t, repository)
|
||||
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
err = checkLocalReplacements(context.Background(), tree)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != LocalReplacements || len(failure.Findings) != 4 {
|
||||
t.Fatalf("checkLocalReplacements() error = %v, want four reused findings", err)
|
||||
}
|
||||
want := []struct {
|
||||
path string
|
||||
line int
|
||||
target string
|
||||
}{
|
||||
{"first/go.mod", 3, "../one"},
|
||||
{"first/go.mod", 4, "../two"},
|
||||
{"second/go.mod", 3, "../one"},
|
||||
{"second/go.mod", 4, "../two"},
|
||||
}
|
||||
for index, finding := range failure.Findings {
|
||||
if finding.Path != want[index].path || finding.Line != want[index].line || finding.Target != want[index].target {
|
||||
t.Errorf("finding[%d] = %#v, want %#v", index, finding, want[index])
|
||||
}
|
||||
}
|
||||
first, _ := tree.Entry("first/go.mod")
|
||||
second, _ := tree.Entry("second/go.mod")
|
||||
shared, _ := tree.Entry("shared/module.mod")
|
||||
if first.ObjectID != second.ObjectID || first.ObjectID == shared.ObjectID {
|
||||
t.Fatalf("declaration object IDs = %q, %q; module body = %q", first.ObjectID, second.ObjectID, shared.ObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLocalReplacementsReportsUnavailableSymlinkContents(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
if err := os.Symlink("missing.mod", filepath.Join(repository, "go.mod")); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -30,19 +31,29 @@ type markdownDocument struct {
|
||||
}
|
||||
|
||||
type markdownCache struct {
|
||||
tree *CandidateTree
|
||||
documents map[string]*markdownDocument
|
||||
tree *CandidateTree
|
||||
byPath map[string]*markdownDocument
|
||||
byObjectID map[string]*markdownDocument
|
||||
}
|
||||
|
||||
func newMarkdownCache(tree *CandidateTree) *markdownCache {
|
||||
return &markdownCache{
|
||||
tree: tree,
|
||||
documents: make(map[string]*markdownDocument),
|
||||
tree: tree,
|
||||
byPath: make(map[string]*markdownDocument),
|
||||
byObjectID: make(map[string]*markdownDocument),
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
|
||||
if document, ok := cache.documents[path]; ok {
|
||||
if document, ok := cache.byPath[path]; ok {
|
||||
return document, nil
|
||||
}
|
||||
entry, ok := cache.tree.Entry(path)
|
||||
if !ok || !isRegularFile(entry) {
|
||||
return nil, fmt.Errorf("committed path %q is not a regular file", path)
|
||||
}
|
||||
if document, ok := cache.byObjectID[entry.ObjectID]; ok {
|
||||
cache.byPath[path] = document
|
||||
return document, nil
|
||||
}
|
||||
source, err := cache.tree.ReadFile(ctx, path)
|
||||
@@ -50,7 +61,9 @@ func (cache *markdownCache) document(ctx context.Context, path string) (*markdow
|
||||
return nil, err
|
||||
}
|
||||
document := parseMarkdown(source)
|
||||
cache.documents[path] = document
|
||||
source = nil
|
||||
cache.byObjectID[entry.ObjectID] = document
|
||||
cache.byPath[path] = document
|
||||
return document, nil
|
||||
}
|
||||
|
||||
@@ -69,15 +82,15 @@ func parseMarkdown(source []byte) *markdownDocument {
|
||||
switch node := node.(type) {
|
||||
case *ast.Link:
|
||||
document.links = append(document.links, markdownLink{
|
||||
Destination: node.Destination.Value(source),
|
||||
Title: node.Title.Value(source),
|
||||
Destination: strings.Clone(string(node.Destination.Value(source))),
|
||||
Title: strings.Clone(string(node.Title.Value(source))),
|
||||
Reference: node.Reference != nil,
|
||||
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||
})
|
||||
case *ast.Image:
|
||||
document.links = append(document.links, markdownLink{
|
||||
Destination: node.Destination.Value(source),
|
||||
Title: node.Title.Value(source),
|
||||
Destination: strings.Clone(string(node.Destination.Value(source))),
|
||||
Title: strings.Clone(string(node.Title.Value(source))),
|
||||
Image: true,
|
||||
Reference: node.Reference != nil,
|
||||
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||
@@ -86,7 +99,7 @@ func parseMarkdown(source []byte) *markdownDocument {
|
||||
return ast.WalkSkipChildren, nil
|
||||
case *ast.AutoLink:
|
||||
document.links = append(document.links, markdownLink{
|
||||
Destination: node.Destination.Value(source),
|
||||
Destination: strings.Clone(string(node.Destination.Value(source))),
|
||||
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||
})
|
||||
case *ast.Heading:
|
||||
|
||||
@@ -150,6 +150,24 @@ func TestCheckMarkdownLinksUsesResolvedMarkdownIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMarkdownLinksUsesCurrentPathForSharedDocuments(t *testing.T) {
|
||||
shared := []byte("[target](target.md)\n")
|
||||
_, tree := committedMarkdownTree(t, map[string][]byte{
|
||||
"one/guide.md": shared,
|
||||
"one/target.md": []byte("# Target\n"),
|
||||
"two/guide.md": shared,
|
||||
})
|
||||
|
||||
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
|
||||
t.Fatalf("checkMarkdownLinks() error = %v, want one path-specific finding", err)
|
||||
}
|
||||
if finding := failure.Findings[0]; finding.Path != "two/guide.md" || finding.Target != "target.md" {
|
||||
t.Fatalf("shared-document finding = %#v, want missing target relative to two/guide.md", finding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations(t *testing.T) {
|
||||
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
||||
"guide.md": []byte("[outside](../../outside.md)\n[submodule](deps/module/README.md)\n"),
|
||||
|
||||
@@ -87,11 +87,13 @@ func TestParseMarkdownHeadingAnchors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath(t *testing.T) {
|
||||
func TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "guide.md", []byte("# Guide\n[other](other.md)\n"))
|
||||
writeTreeFile(t, repository, "other.md", []byte("# Other\n"))
|
||||
gitTree(t, repository, "add", "guide.md", "other.md")
|
||||
shared := []byte("# Guide\n[other](other.md \"title\")\n")
|
||||
writeTreeFile(t, repository, "one/guide.md", shared)
|
||||
writeTreeFile(t, repository, "two/guide.md", shared)
|
||||
writeTreeFile(t, repository, "one/other.md", []byte("# Other\n"))
|
||||
gitTree(t, repository, "add", "one/guide.md", "two/guide.md", "one/other.md")
|
||||
gitTree(t, repository, "commit", "-m", "add markdown files")
|
||||
|
||||
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||
@@ -101,25 +103,28 @@ func TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath(t *testing.T) {
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
cache := newMarkdownCache(tree)
|
||||
first, err := cache.document(context.Background(), "guide.md")
|
||||
first, err := cache.document(context.Background(), "one/guide.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := cache.document(context.Background(), "guide.md")
|
||||
second, err := cache.document(context.Background(), "two/guide.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("repeated document lookup returned a different parsed document")
|
||||
t.Fatal("identical Markdown blobs returned different parsed documents")
|
||||
}
|
||||
other, err := cache.document(context.Background(), "other.md")
|
||||
other, err := cache.document(context.Background(), "one/other.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == first {
|
||||
t.Fatal("different candidate paths shared a parsed document")
|
||||
t.Fatal("different Markdown blobs shared a parsed document")
|
||||
}
|
||||
if _, ok := other.anchors["other"]; !ok {
|
||||
t.Fatalf("other document anchors = %#v", other.anchors)
|
||||
}
|
||||
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
|
||||
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user