Skip to content

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

Relative Markdown links to untracked paths.

Require relative Markdown links, images, and definitions to point at a git-tracked path.

  • broken: Broken relative link “{{target}}”: no tracked file or directory at that path.
[
{
"roots": []
}
]

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.

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:, or vscode:
  • 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.

The rule reads git ls-files for the repository, not the disk. Two cases make the disk misleading:

  1. macOS file systems are case-insensitive by default, so ./SETUP.md opens setup.md locally and then fails on a Linux CI runner.
  2. 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.

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 cwd is broken.
  • A file outside cwd is not checked.
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.

eslint.config.mjs
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.

  • ESLint --cache keys 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 --cache in CI, or clear the cache after moving files.
  • The rule has no autofix.
  • Only .md files are linted by the preset. MDX and other Markdown extensions need their own config.

All rules · ESLint rules