Check committed Markdown destinations

This commit is contained in:
2026-10-07 23:17:10 +00:00
parent b3b959ce77
commit 6397d41517
6 changed files with 440 additions and 11 deletions

View File

@@ -23,6 +23,8 @@ const (
LocalReplacements Check = "local-replacements"
// FileSize identifies committed blobs that exceed a selected size limit.
FileSize Check = "file-size"
// MarkdownLinks identifies missing committed Markdown destinations or anchors.
MarkdownLinks Check = "markdown-links"
)
// Finding describes a validation observation at an optional source location.

View File

@@ -0,0 +1,184 @@
package validation
import (
"context"
"fmt"
"io"
"net/url"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckoutBoundary bool, output io.Writer) error {
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Cause: err}
}
cache := newMarkdownCache(tree)
var findings []Finding
for _, entry := range tree.Entries() {
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
if !isRegularFile(entry) || !strings.EqualFold(path.Ext(entry.Path), ".md") {
continue
}
document, err := cache.document(ctx, entry.Path)
if err != nil {
return &Failure{
Check: MarkdownLinks,
Findings: []Finding{{Path: entry.Path, Message: "could not inspect Markdown document"}},
Cause: fmt.Errorf("read committed Markdown document %q: %w", entry.Path, err),
}
}
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
for _, link := range document.links {
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
linkFindings, err := checkMarkdownLink(ctx, tree, cache, entry.Path, link, enforceCheckoutBoundary, output)
findings = append(findings, linkFindings...)
if err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
}
}
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
if len(findings) != 0 {
return &Failure{Check: MarkdownLinks, Findings: findings}
}
return nil
}
func checkMarkdownLink(ctx context.Context, tree *CandidateTree, cache *markdownCache, sourcePath string, link markdownLink, enforceCheckoutBoundary bool, output io.Writer) ([]Finding, error) {
uriPath, fragment, local, err := parseMarkdownDestination(link.Destination)
if err != nil {
return []Finding{markdownFinding(sourcePath, link, "destination contains an invalid percent escape")}, nil
}
if !local {
return nil, nil
}
destination := Destination{Kind: DestinationEntry, Path: sourcePath}
if uriPath != "" {
destination, err = tree.ResolveDestination(ctx, sourcePath, uriPath)
if err != nil {
finding := markdownFinding(sourcePath, link, "could not inspect destination")
return []Finding{finding}, fmt.Errorf("resolve Markdown destination %q from %q: %w", link.Destination, sourcePath, err)
}
}
switch destination.Kind {
case DestinationMissing:
return []Finding{markdownFinding(sourcePath, link, "destination does not exist")}, nil
case DestinationSymlinkCycle:
return []Finding{markdownFinding(sourcePath, link, "destination contains a symlink cycle")}, nil
case DestinationOutsideCheckout:
if enforceCheckoutBoundary {
return []Finding{markdownFinding(sourcePath, link, "destination is outside the checkout")}, nil
}
return nil, writeMarkdownObservation(output, sourcePath, link, "destination is outside the checkout")
case DestinationSubmodule:
return nil, writeMarkdownObservation(output, sourcePath, link, "destination enters uninspected submodule content")
case DestinationEntry:
if fragment == "" || !strings.EqualFold(path.Ext(destination.Path), ".md") {
return nil, nil
}
targetEntry, ok := tree.Entry(destination.Path)
if !ok || !isRegularFile(targetEntry) {
return nil, nil
}
targetDocument, err := cache.document(ctx, destination.Path)
if err != nil {
finding := markdownFinding(sourcePath, link, "could not inspect destination anchor")
return []Finding{finding}, fmt.Errorf("read committed Markdown destination %q: %w", destination.Path, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
if _, ok := targetDocument.anchors[fragment]; !ok {
return []Finding{markdownFinding(sourcePath, link, "heading anchor does not exist")}, nil
}
}
return nil, nil
}
func parseMarkdownDestination(destination string) (path, fragment string, local bool, err error) {
pathPart := destination
if index := strings.IndexByte(pathPart, '#'); index >= 0 {
fragment = pathPart[index+1:]
pathPart = pathPart[:index]
}
if index := strings.IndexByte(pathPart, '?'); index >= 0 {
pathPart = pathPart[:index]
}
if strings.HasPrefix(pathPart, "//") || hasURLScheme(pathPart) {
return "", "", false, nil
}
path, err = url.PathUnescape(pathPart)
if err != nil {
return "", "", true, err
}
fragment, err = url.PathUnescape(fragment)
if err != nil {
return "", "", true, err
}
return path, strings.ToLower(fragment), true, nil
}
func hasURLScheme(value string) bool {
colon := strings.IndexByte(value, ':')
if colon <= 0 {
return false
}
if slash := strings.IndexByte(value, '/'); slash >= 0 && slash < colon {
return false
}
for index, r := range value[:colon] {
if index == 0 {
if !isASCIIAlpha(r) {
return false
}
continue
}
if !isASCIIAlpha(r) && (r < '0' || r > '9') && r != '+' && r != '-' && r != '.' {
return false
}
}
return true
}
func isASCIIAlpha(r rune) bool {
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
}
func isRegularFile(entry git.TreeEntry) bool {
return entry.Type == "blob" && (entry.Mode == "100644" || entry.Mode == "100755")
}
func markdownFinding(sourcePath string, link markdownLink, message string) Finding {
return Finding{
Path: sourcePath,
Line: link.Location.Line,
Column: link.Location.Column,
Message: message,
Target: link.Destination,
}
}
func writeMarkdownObservation(output io.Writer, sourcePath string, link markdownLink, message string) error {
if output == nil {
return nil
}
_, err := fmt.Fprintf(output, "markdown-links: %s:%d:%d: %s (%q; out of scope)\n",
sourcePath, link.Location.Line, link.Location.Column, message, link.Destination)
if err != nil {
return fmt.Errorf("write Markdown destination observation: %w", err)
}
return nil
}

View File

@@ -0,0 +1,223 @@
package validation
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments(t *testing.T) {
guide := []byte("" +
"# Start\n" +
"[target](target.md?view=1#TARGET)\n" +
"[same](#START)\n" +
"[top](#)\n" +
"[directory](assets/?view=1)\n" +
"[binary](image.bin#not-an-anchor)\n" +
"[encoded](docs/a%20b.md#CAF%C3%89)\n" +
"[query](target.md?view=%ZZ)\n" +
"[external](https://example.invalid/%ZZ#also%ZZ)\n" +
"[protocol-relative](//example.invalid/file.md)\n")
repository, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": guide,
"target.md": []byte("# Target\n"),
"docs/a b.md": []byte("# Café\n"),
"assets/file.md": []byte("# Asset\n"),
"nested/guide.md": []byte("# Nested\n[same](#NESTED)\n[query](?view=1#nested)\n"),
"image.bin": []byte{0, 1, 2},
})
if err := checkMarkdownLinks(context.Background(), tree, false, nil); err != nil {
t.Fatalf("checkMarkdownLinks() error = %v", err)
}
if got := readCommitted(t, tree, "guide.md"); !bytes.Equal(got, guide) {
t.Fatal("checker changed committed Markdown bytes")
}
if got, err := os.ReadFile(filepath.Join(repository, "guide.md")); err != nil || !bytes.Equal(got, guide) {
t.Fatalf("worktree Markdown changed: got %q, err %v", got, err)
}
}
func TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles(t *testing.T) {
_, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("" +
"[missing](absent.md)\n" +
"[anchor](target.md#missing)\n" +
"[cycle](cycle-a)\n"),
"target.md": []byte("# Present\n"),
})
repository := tree.repositoryDir
if err := os.Symlink("cycle-b", filepath.Join(repository, "cycle-a")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("cycle-a", filepath.Join(repository, "cycle-b")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "cycle-a", "cycle-b")
gitTree(t, repository, "commit", "-m", "add markdown link cycle")
tree = candidateTreeAtHead(t, repository)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) {
t.Fatalf("checkMarkdownLinks() error = %v, want validation Failure", err)
}
if failure.Check != MarkdownLinks {
t.Fatalf("failure check = %q, want %q", failure.Check, MarkdownLinks)
}
if len(failure.Findings) != 3 {
t.Fatalf("findings = %#v, want missing path, anchor, and cycle", failure.Findings)
}
want := map[string]string{
"absent.md": "destination does not exist",
"target.md#missing": "heading anchor does not exist",
"cycle-a": "destination contains a symlink cycle",
}
for _, finding := range failure.Findings {
if finding.Path != "guide.md" || finding.Line < 1 || finding.Column < 1 {
t.Errorf("finding lacks source location: %#v", finding)
}
if want[finding.Target] != finding.Message {
t.Errorf("finding = %#v", finding)
}
delete(want, finding.Target)
}
if len(want) != 0 {
t.Fatalf("missing expected findings for %#v", want)
}
}
func TestCheckMarkdownLinksUsesResolvedMarkdownIdentity(t *testing.T) {
repository, _ := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[valid](alias.md#present)\n[missing](alias.md#missing)\n"),
"target.md": []byte("# Present\n"),
})
if err := os.Symlink("target.md", filepath.Join(repository, "alias.md")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "alias.md")
gitTree(t, repository, "commit", "-m", "add Markdown alias")
tree := candidateTreeAtHead(t, repository)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
t.Fatalf("resolved alias error = %v, want one missing-anchor finding", err)
}
if finding := failure.Findings[0]; finding.Path != "guide.md" || finding.Target != "alias.md#missing" || finding.Message != "heading anchor does not exist" {
t.Fatalf("resolved alias finding = %#v", 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"),
})
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
gitTree(t, repository, "update-index", "--add", "--cacheinfo", fmt.Sprintf("160000,%s,deps/module", commit))
gitTree(t, repository, "commit", "-m", "add submodule entry")
tree = candidateTreeAtHead(t, repository)
var observed bytes.Buffer
if err := checkMarkdownLinks(context.Background(), tree, false, &observed); err != nil {
t.Fatalf("checkMarkdownLinks() error = %v", err)
}
if got := observed.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "uninspected submodule content") || strings.Count(got, "out of scope") != 2 {
t.Fatalf("out-of-scope observations = %q", got)
}
observed.Reset()
err := checkMarkdownLinks(context.Background(), tree, true, &observed)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
t.Fatalf("enforced boundary error = %v, want one Markdown finding", err)
}
if finding := failure.Findings[0]; finding.Target != "../../outside.md" || finding.Message != "destination is outside the checkout" {
t.Fatalf("boundary finding = %#v", finding)
}
if got := observed.String(); !strings.Contains(got, "uninspected submodule content") || strings.Contains(got, "outside the checkout") {
t.Fatalf("enforced observations = %q", got)
}
}
func TestCheckMarkdownLinksPreservesResolverCauseAndCancellation(t *testing.T) {
repository, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[target](broken.md)\n"),
})
if err := os.Symlink("target.md", filepath.Join(repository, "broken.md")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "broken.md")
gitTree(t, repository, "commit", "-m", "add Markdown symlink")
tree = candidateTreeAtHead(t, repository)
index := tree.byPath["broken.md"]
tree.entries[index].ObjectID = strings.Repeat("0", 40)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
t.Fatalf("resolver inspection error = %v, want Markdown failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Line != 1 || failure.Findings[0].Target != "broken.md" {
t.Fatalf("resolver inspection finding = %#v", failure.Findings)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = checkMarkdownLinks(ctx, tree, false, nil)
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled Markdown check error = %v, want preserved cancellation", err)
}
}
func TestCheckMarkdownLinksPreservesAnchorReadCause(t *testing.T) {
_, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[target](target.md#present)\n"),
"target.md": []byte("# Present\n"),
})
tree.entries[tree.byPath["target.md"]].ObjectID = strings.Repeat("0", 40)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
t.Fatalf("anchor inspection error = %v, want Markdown failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "target.md#present" || failure.Findings[0].Message != "could not inspect destination anchor" {
t.Fatalf("anchor inspection finding = %#v", failure.Findings)
}
}
func committedMarkdownTree(t *testing.T, files map[string][]byte) (string, *CandidateTree) {
t.Helper()
repository := newTreeRepository(t)
for name, content := range files {
writeTreeFile(t, repository, name, content)
}
gitTree(t, repository, "add", "--", ".")
gitTree(t, repository, "commit", "-m", "add Markdown fixture")
return repository, candidateTreeAtHead(t, repository)
}
func candidateTreeAtHead(t *testing.T, repository string) *CandidateTree {
t.Helper()
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
return tree
}
func readCommitted(t *testing.T, tree *CandidateTree, name string) []byte {
t.Helper()
contents, err := tree.ReadFile(context.Background(), name)
if err != nil {
t.Fatal(err)
}
return contents
}