Check committed Markdown destinations
This commit is contained in:
@@ -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` |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
184
internal/validation/markdown_check.go
Normal file
184
internal/validation/markdown_check.go
Normal 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
|
||||||
|
}
|
||||||
223
internal/validation/markdown_check_test.go
Normal file
223
internal/validation/markdown_check_test.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user