no-broken-relative-links
| Field | Value |
|---|---|
| Tool | ESLint |
| Enable via | @house-rules/rules/markdown |
| Rule ID | house-rules/no-broken-relative-links |
| Type | problem |
| Autofix | no |
| Source | packages/rules/src/no-broken-relative-links.mjs |
| Docs | packages/rules/docs/no-broken-relative-links.md |
What it catches
Section titled “What it catches”Relative Markdown links to untracked paths.
In one line
Section titled “In one line”Require relative Markdown links, images, and definitions to point at a git-tracked path.
Messages
Section titled “Messages”broken: Broken relative link “{{target}}”: no tracked file or directory at that path.
Default options
Section titled “Default options”[ { "roots": [] }]Rule docs
Section titled “Rule docs”From packages/rules/docs/no-broken-relative-links.md.
Rule ID: house-rules/no-broken-relative-links
The rule runs on the @eslint/markdown language. It reports a Markdown link, image, or link reference definition when its relative target is not a path that git tracks in the repository that holds the linted file. Use the @house-rules/rules/markdown preset to enable it.
See the [setup guide](./setup.md). <!-- passes when docs/setup.md is tracked -->See the [old guide](./old-setup.md). <!-- reported: no tracked docs/old-setup.md -->See the [setup guide](./SETUP.md). <!-- reported: case differs from docs/setup.md -->The message names the target as written: Broken relative link "./old-setup.md": no tracked file or directory at that path. The report covers the whole link, image, or definition node.
What the rule checks
Section titled “What the rule checks”The rule visits mdast link, image, and definition nodes after @eslint/markdown has parsed the file. Code blocks, code spans, HTML blocks, HTML comments, and YAML front matter never produce those nodes, so links inside them are not checked. Footnote definitions are not link definitions and are not checked.
It skips these targets:
- an empty target
- a pure anchor such as
#install - a protocol-relative URL such as
//cdn.example.com/x.js - any target with a URI scheme, such as
https:,mailto:, orvscode: - any target containing
{, which covers template placeholders such as{pr_url}
For every other target, the rule drops the #fragment and ?query, decodes percent escapes, and resolves the result:
- A target starting with
/resolves from the repository root. - Any other target resolves from the linted file’s directory.
- A target that climbs out of the repository root is broken.
- A directory target passes, with or without a trailing slash, when git tracks at least one file under it.
The rule does not check fragments against headings. Raw HTML href and src attributes are not checked either.
Why only tracked paths count
Section titled “Why only tracked paths count”The rule reads git ls-files for the repository, not the disk. Two cases make the disk misleading:
- macOS file systems are case-insensitive by default, so
./SETUP.mdopenssetup.mdlocally and then fails on a Linux CI runner. - CI checks out committed files only, so a link to a file you have not added yet works on your machine and breaks everywhere else.
The rule uses the index, so a file you git add counts before you commit it. The comparison is exact and case-sensitive.
The rule finds the repository with git rev-parse --show-toplevel from the linted file’s directory. It runs git ls-files once per repository root and caches the result. Before each lookup it checks the index file, found with git rev-parse --git-path index, so worktrees and submodules work too. When the index changes, the rule runs git ls-files again. So in a long-running process such as an editor language server, git add, git mv and git rm take effect on the next lint without a restart. Before the first git add there is no index, and every target counts as broken.
Outside a git repository
Section titled “Outside a git repository”When the linted file is not inside a git repository, or git is not installed, the rule does not crash. It falls back to the file system:
/targets resolve from ESLint’s working directory (cwd).- A target counts when it exists on disk with the exact case of every path segment.
- A target that climbs out of
cwdis broken. - A file outside
cwdis not checked.
Options
Section titled “Options”type Options = [ { roots?: string[]; // default: [] },];roots lists subdirectories that act as their own link root, relative to the repository root. Use it for a subtree that is published as its own repository or site, where a link that leaves the subtree breaks after publication.
For a file under one of the roots:
- A
/target resolves from that root, not from the repository root. - A target that climbs out of that root is broken, even when the path exists in the repository.
When roots are nested, the longest matching root wins. ./ prefixes and trailing slashes are ignored, so "./packages/sdk/" and "packages/sdk" mean the same root. Each entry must be a non-empty string, and entries must be unique.
import houseRulesMarkdown from "@house-rules/rules/markdown";
export default [ ...houseRulesMarkdown, { files: ["**/*.md"], rules: { "house-rules/no-broken-relative-links": ["error", { roots: ["packages/sdk"] }], }, },];With that config, packages/sdk/docs/guide.md may link to /README.md, which resolves to packages/sdk/README.md. A link from it to ../../docs/setup.md is reported.
Limitations
Section titled “Limitations”- ESLint
--cachekeys results on the linted file. If you delete or rename only the target file, the file holding the link is unchanged, and a cached run can miss the broken link. Run without--cachein CI, or clear the cache after moving files. - The rule has no autofix.
- Only
.mdfiles are linted by the preset. MDX and other Markdown extensions need their own config.