diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 000a425..6971a04 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -46,7 +46,7 @@ reports these implemented packages: | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | -| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, internal Markdown parsing and per-document anchor caching, structured failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | +| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, internal Markdown parsing, destination and fragment checking, and per-document anchor caching; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | @@ -79,7 +79,7 @@ not a separate release matrix: | --- | --- | --- | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestLoadSettingsReadsGoValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | -| Internal Markdown syntax records, source use sites, heading anchors, and candidate-document cache | `internal/validation` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath` | +| Internal Markdown parsing, committed destinations, fragments, boundary observations, and causes | `internal/validation` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | diff --git a/docs/internal/validation.md b/docs/internal/validation.md index 1a47b0f..320c9e9 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -50,9 +50,19 @@ autolinks, images, titles, and their source use sites, and to build heading anchors from visible AST text. It supports ATX and Setext headings, decodes Markdown text through the parser, and assigns collision-free numeric suffixes to repeated anchors. Link locations use one-based line and byte-column values. -A per-path cache retains the parsed records and anchors for each candidate -document. The parser remains internal; destination resolution and check selection -are tracked in the [implementation plan](../roadmap/implementation.md). +A per-path cache retains parsed records and anchors for candidate documents. + +`checkMarkdownLinks` examines regular `.md` blobs from the captured tree. It +skips schemed and protocol-relative URIs, strips query components, and decodes +percent escapes once in local paths and fragments. It resolves destinations +through `CandidateTree.ResolveDestination`; decoded fragments are compared in +lowercase against anchors from the resolved regular Markdown document. Missing +paths, symlink cycles, and missing anchors produce `markdown-links` findings at +the source use site. Outside-checkout links produce out-of-scope observations by +default and findings when boundary enforcement is selected. Submodule-content +observations remain out of scope under either setting. Observations go to the +provided writer; a nil writer keeps library use quiet. Public selection remains +in the [implementation plan](../roadmap/implementation.md). Committed-tree metadata and blob reads disable lazy fetching and Git transports for those child commands. Missing local objects produce inspection errors; @@ -126,6 +136,6 @@ symlink expansion detects cycles, and traversal honors cancellation. Absolute destinations and symlink targets within the repository directory map back to candidate-relative paths; existence still comes from committed entries. -The [implementation plan](../roadmap/implementation.md) owns Markdown -destination checking, diagnostics, and boundary policy. The committed-file and -destination-resolution primitives are available for that later integration. +The [implementation plan](../roadmap/implementation.md) records the remaining +public Markdown selection and integration work. The internal checker uses the +committed-file and destination-resolution primitives described here. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 14c25e2..12475e2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -200,9 +200,10 @@ the parser. Code blocks and inline code supply examples rather than link nodes. Raw HTML destinations remain outside this Markdown-link capability. Undefined references rendered as ordinary text by the parser are not a new Markdown lint rule. Decode Markdown escapes/entities through the parser and percent escapes -once when resolving URI path/fragment components. Ignore URI query components -for file existence. Empty paths target the source document; an empty fragment -selects its top. Schemed and protocol-relative URIs receive no availability +once when resolving URI path/fragment components. Compare decoded fragments in +lowercase to preserve PromptKit's starting convention. Ignore URI query +components for file existence. Empty paths target the source document; an empty +fragment selects its top. Schemed and protocol-relative URIs receive no availability request. Fragment checking applies to regular Markdown destinations; other existing files/directories receive existence checking. @@ -443,6 +444,11 @@ Undefined references and HTML prose remain governed by parser interpretation. ## Stage 10: Implement internal destination and fragment checking +**Completion:** Complete. The internal checker resolves committed destinations, +checks supported fragments, preserves inspection errors, and reports distinct +outside-checkout and submodule observations. Public selection and shared executor +wiring remain in Stage 11. + **Outcome:** Committed Markdown navigation is checked with boundary observations. 1. Resolve parsed local URI components through `CandidateTree.ResolveDestination`. @@ -456,6 +462,10 @@ Undefined references and HTML prose remain governed by parser interpretation. 4. Reuse remediated symlink/cancellation/local-object behavior. Keep parsing, resolution, and rule evaluation in ordinary internal functions. +**Resolution refinement:** URI query components do not participate in path +checking. Compare fragments after one percent-decoding pass and lowercase them +to preserve PromptKit's starting anchor matching. + **Verification:** Valid/missing files and anchors, same-document/empty fragments, percent-escaped paths and fragments, query strings, directories and non-Markdown files, outside links with both settings, and submodule observations. Check how diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go index 85dbdf1..7055065 100644 --- a/internal/validation/diagnostic.go +++ b/internal/validation/diagnostic.go @@ -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. diff --git a/internal/validation/markdown_check.go b/internal/validation/markdown_check.go new file mode 100644 index 0000000..6f5d0d8 --- /dev/null +++ b/internal/validation/markdown_check.go @@ -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 +} diff --git a/internal/validation/markdown_check_test.go b/internal/validation/markdown_check_test.go new file mode 100644 index 0000000..ebbeb77 --- /dev/null +++ b/internal/validation/markdown_check_test.go @@ -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 +}