linkcheck
Fails a build when a relative link in a repository's Markdown does not resolve.
$ linkcheck -root .
docs/design/ADR-011.md:42: broken link -> ../srd/SRD-999-missing.md
exit status 1
Install
go install github.com/dr-dobermann/linkcheck/cmd/linkcheck@latest
Why it exists
Documentation links rot silently. Nothing fails when a doc is renamed, so the
breakage is discovered by a reader, months later, one link at a time. A single
sweep of one repository turned up 78 dead cross-references accumulated
across several refactors — including its README and its top-level architecture
document.
It is a small Go program rather than an off-the-shelf checker because a CI gate
should be pinned, offline and installable with go install. Alternatives add a
non-Go toolchain plus a network dependency that reddens the gate for reasons
unrelated to the change under test.
Use in CI
Do not install with @latest in a gate: a floating version changes what
your build enforces without anything in your repository changing. Pin it, and
make a missing binary fail loudly rather than skip:
LINKCHECK_VERSION := v0.1.1
# One-time per machine / CI image.
tools:
go install github.com/dr-dobermann/linkcheck/cmd/linkcheck@$(LINKCHECK_VERSION)
.PHONY: tools
link-check:
@command -v linkcheck >/dev/null 2>&1 || { echo "ERROR: 'linkcheck' not in PATH. Run 'make tools'."; exit 1; }
linkcheck -root .
.PHONY: link-check
ci: link-check # ... alongside your other gate steps
The guard matters more than it looks. Without it, a missing binary makes the
step a silent no-op — the gate "passes" locally because the tool was never
installed, and fails in CI where it was. That failure mode is why the check is
worth wiring as a hard requirement rather than a best-effort convenience.
Run it blocking, not advisory. Dead links accumulate precisely because
nothing fails; a warning nobody must act on is how the 78 got there.
What it checks
Every relative link target in every .md file under -root, including
reference-style definitions ([id]: path). A target that does not resolve to
an existing file is reported as file:line.
What it deliberately ignores
Both exclusions are requirements learned from real false positives, not
conveniences:
- Fenced and inline code. A Go generic signature such as
`values.NewArray[T](vals…)` is indistinguishable from a Markdown link
to a naive regex. One repository had eight such spans.
- Absolute and protocol-relative URLs,
mailto:, and same-document anchors.
They rot for reasons outside the repository, and verifying them would make
the gate depend on the network.
Targets are percent-decoded before resolution, or every correct link to a
file whose name contains a space reports as broken. Bracketed targets
([text](<path with spaces>)) and titles ([text](path "title")) are handled.
Exit codes
| Code |
Meaning |
| 0 |
every relative link resolves |
| 1 |
at least one link is broken (each printed as file:line) |
| 2 |
usage error — an unreadable root, a bad flag |
License
LGPL-3.0, the same as the project it was extracted from.