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

@@ -46,7 +46,7 @@ reports these implemented packages:
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `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/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/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `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` | | 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` | | 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` | | 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` | | 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` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |

View File

@@ -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 anchors from visible AST text. It supports ATX and Setext headings, decodes
Markdown text through the parser, and assigns collision-free numeric suffixes Markdown text through the parser, and assigns collision-free numeric suffixes
to repeated anchors. Link locations use one-based line and byte-column values. 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 A per-path cache retains parsed records and anchors for candidate documents.
document. The parser remains internal; destination resolution and check selection
are tracked in the [implementation plan](../roadmap/implementation.md). `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 Committed-tree metadata and blob reads disable lazy fetching and Git transports
for those child commands. Missing local objects produce inspection errors; 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 destinations and symlink targets within the repository directory map back to
candidate-relative paths; existence still comes from committed entries. candidate-relative paths; existence still comes from committed entries.
The [implementation plan](../roadmap/implementation.md) owns Markdown The [implementation plan](../roadmap/implementation.md) records the remaining
destination checking, diagnostics, and boundary policy. The committed-file and public Markdown selection and integration work. The internal checker uses the
destination-resolution primitives are available for that later integration. committed-file and destination-resolution primitives described here.

View File

@@ -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 Raw HTML destinations remain outside this Markdown-link capability. Undefined
references rendered as ordinary text by the parser are not a new Markdown lint 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 rule. Decode Markdown escapes/entities through the parser and percent escapes
once when resolving URI path/fragment components. Ignore URI query components once when resolving URI path/fragment components. Compare decoded fragments in
for file existence. Empty paths target the source document; an empty fragment lowercase to preserve PromptKit's starting convention. Ignore URI query
selects its top. Schemed and protocol-relative URIs receive no availability 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 request. Fragment checking applies to regular Markdown destinations; other
existing files/directories receive existence checking. 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 ## 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. **Outcome:** Committed Markdown navigation is checked with boundary observations.
1. Resolve parsed local URI components through `CandidateTree.ResolveDestination`. 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, 4. Reuse remediated symlink/cancellation/local-object behavior. Keep parsing,
resolution, and rule evaluation in ordinary internal functions. 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, **Verification:** Valid/missing files and anchors, same-document/empty fragments,
percent-escaped paths and fragments, query strings, directories and non-Markdown percent-escaped paths and fragments, query strings, directories and non-Markdown
files, outside links with both settings, and submodule observations. Check how files, outside links with both settings, and submodule observations. Check how

View File

@@ -23,6 +23,8 @@ const (
LocalReplacements Check = "local-replacements" LocalReplacements Check = "local-replacements"
// FileSize identifies committed blobs that exceed a selected size limit. // FileSize identifies committed blobs that exceed a selected size limit.
FileSize Check = "file-size" 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. // 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
}