Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
ubCode 0.34.0 documentation
Light Logo Dark Logo

Getting started

  • What is ubCode?
  • Key concepts
    • Glossary
  • Installation
  • Quickstart

Configuration & authoring

  • Configuration
    • Needs
    • Card designs with [needs.card_layouts]
    • Field links with [needs.string_links]
    • Schema validation
    • Deprecated needs options
    • Codelinks (source code tracing)
    • Intersphinx
    • Link checking
    • Parsing
    • Linting
    • Formatting
    • Server
    • Project, source & scripts
    • HTML site
    • Workflow
    • Quality Analysis
  • Authoring
    • Directives
    • Roles
    • Needs
    • Variants
    • Diagrams
    • Markdown (MyST)
    • Differences from a Sphinx build

Core workflows

  • Needs tutorial
  • Coming from Sphinx-Needs
  • What works inside imported need content
  • Tracing source code with Codelinks
  • Pharaoh Agentic Engineer
    • Quick start
    • Configuring the workflow
    • Reviews and quality analysis
    • The ubc agent command
    • In VS Code

Guides

  • Writing a filter
  • Writing reports
  • Building a static HTML site
  • Variant builds
  • Using ubc in CI/CD

Reference

  • Features
    • Home view
    • Linting
    • RST preview
    • Realtime index
    • Model Context Protocol (MCP) server
    • Chat participant
    • Needs filtering
    • Needs graph view
    • Navigation
    • Site Map
    • Commands
    • Diff & impact analysis
    • needs.json view
  • Supported toolchain

Support

  • Troubleshooting
  • Request a license
  • Report an issue

Development

  • Roadmap
  • Changelog
    • 0.34.x
    • 0.33.x
    • 0.32.x
    • 0.31.x
    • 0.30.x
    • 0.29.x
    • 0.28.x
    • 0.27.x
    • 0.26.x
    • 0.25.x
    • 0.24.x
    • 0.23.x
    • 0.22.x
    • 0.21.x
    • 0.20.x
    • 0.19.x
    • 0.18.x
    • 0.17.x
    • 0.16.x
    • 0.15.x
    • 0.14.x
    • 0.13.x
    • 0.12.x
    • 0.11.x
    • 0.10.x
    • 0.9.x
    • 0.8.x
    • 0.7.x
    • 0.6.x
    • 0.5.x
    • 0.4.x
    • 0.3.x
    • 0.2.x
Back to top
View this page
Edit this page

Link checking¶

Added in version 0.34.0.

ubc build linkcheck requests every external URL your project references and grades the answer.

It is built to be run as a blocking CI step, which is the part that usually goes wrong: a checker that is slow, that fails on somebody else’s outage, or that reports a bot-block as a dead link gets turned off, and a turned-off checker finds nothing. Three design choices follow from that, and they are worth knowing before you read the options:

  • Every occurrence is reported. A URL is requested once however many times it is written, but a dead link used in twelve files is twelve locations to fix, each with its own file:line:column — or as much of one as is actually known, never a guessed position.

  • Verdicts are richer than pass/fail. A 403 from a bot-blocker, somebody else’s 5xx outage, a timeout and a permanent redirect are four different things with four different severities. By default only a verified-dead link fails the command.

  • Results are cached, failures are not. A second run over an unchanged project makes no network requests at all; a link that has been fixed is noticed on the very next run. The one exception is a missing anchor, which is stored beside the page’s own result and expires with it — see Anchors.

Minimal example¶

[linkcheck]
deny = "error"                 # gate bar: none | info | warning | error
ignore = ["^https://internal\\.example/"]
timeout = 30.0                 # seconds per HTTP request
workers = 8
cache_days = 5                 # 0 = always re-check, negative = never expire
offline = false                # never fetch; serve stored results
rate_limit_ceiling = 300.0     # longest honoured Retry-After, in seconds
max_redirects = 10
user_agent = ""                # "" = ubc/<version>
allow_private = false          # permit intranet / loopback targets

[linkcheck.allowed_redirects]
"^https://old\\.example/" = "^https://new\\.example/"

[linkcheck.hosts."slow.example.com"]
max_concurrent = 1             # default: 2
min_interval_ms = 1000         # default: 250

[linkcheck.request_headers."https://api.example.com/"]
Accept = "application/json"
Authorization = "Bearer ${EXAMPLE_TOKEN}"

Running it¶

ubc build linkcheck                     # the project in the current directory
ubc build linkcheck docs/               # an explicit project root
ubc build linkcheck --show-links        # print every finding, even on a pass
ubc build linkcheck --output-format json
ubc build linkcheck --output-format sarif
ubc build linkcheck --refresh           # ignore stored results
ubc build linkcheck --fix-redirects --dry-run   # what would be rewritten
ubc build linkcheck --fix-redirects             # rewrite it
ubc build linkcheck --offline           # serve stored results only

The command prints one summary line, and — on a failing run, or with --show-links — the findings grouped by URL with every occurrence beneath them, each headed by its severity and code (see Linting on the command line):

error[linkcheck.broken]
  --> (reference)
      docs/guide.rst:42:17
  --> (reference)
      docs/index.rst:8:3
  https://example.test/gone: HTTP 404 Not Found

12 link(s) checked (9 from cache, 3 request(s)): 1 broken, 2 permanently redirected.

A failing run prints its detail without being asked, so you never have to re-run the command to learn what failed.

Note

ubc build linkcheck requires an active license — a commercial one, or the automatic open-source allowance — however small the project. There is no unlicensed free tier here, unlike the size-capped allowance the indexing commands have; a run without a license is refused before any indexing or network work, with exit code 1. The cache dir | list | clean subcommands stay available without a license: they manage local state and check nothing.

Verdict classes¶

Each class has its own linkcheck.* code and its own default severity, which is what the deny bar compares against.

Code

Meaning

Severity

linkcheck.broken

Verified dead: DNS did not resolve, the connection was refused, TLS failed, or the server answered 404 / 410.

error

linkcheck.anchor_missing

The page is live but the #fragment is absent — see Anchors.

warning

linkcheck.redirect_permanent

301 / 308 to a different URL that no allowed_redirects rule permits. The report carries the target, which is the fix.

warning

linkcheck.redirect_temporary

302 / 303 / 307 to a different URL. Your URL is not wrong; the server is moving you today.

info

linkcheck.blocked

Reachable, but the answer does not say whether the link works: 401, 403, 407, 451, or a 429 asking for longer than rate_limit_ceiling.

warning

linkcheck.server_error

5xx, 503 included. The server’s outage, not your document’s bug — but never a silent pass either.

warning

linkcheck.timeout

The request exceeded its budget, after the retries its class earns.

warning

linkcheck.anchor_missing is a warning rather than an error, which is the one severity in that table worth explaining. A missing anchor leaves the reader on a live page at the wrong scroll position, not on a dead one — and unlike a 404, it is a claim about a rendered page this tool reconstructed from the bytes it chose to read. Every way that claim can be wrong is closed by design except one: anchors that a site injects with JavaScript, on a host with no quirk entry. That set is defined by other people’s frontend choices, so failing builds on it by default is a promise this tool cannot keep. --deny warning (or [linkcheck] deny) is one line if you want the strict bar, and the default is due for review one release after it ships.

Three further outcomes appear in the report and produce no finding:

working

The URL answered successfully.

ignored

The URL matched an ignore pattern and was never requested. It is still listed, so a suppression is visible rather than silent.

unchecked

The URL uses a scheme this checker does not verify (mailto:, ftp:, tel:, …), points at a private or loopback address while allow_private is off, or — in --offline mode — has no stored result.

The linkcheck.* codes go through the ordinary lint filters: [lint] ignore suppresses a whole class, [lint] message-ignores narrows one to particular messages, and [lint.per-file-ignores] suppresses classes for particular source files — the last of these per occurrence, so a rule naming one directory leaves the same URL’s other occurrences standing. Suppressed findings appear in the summary line as N suppressed by [lint]. That number counts findings — a missing anchor is silenced in its own right, so it is counted there even though summary.checked does not count it as a URL, and the total can therefore exceed the number of links reported as checked. Suppressing a page also hides its unverified and not_checked anchor counts, on the same principle: a finding the project asked not to be told about should not leave a number behind.

Options¶

deny¶

Type: string (default: "error")

The severity at or above which a finding fails the command: none, info, warning or error.

The default means only a verified-dead link fails a build. Redirects, bot-blocks, outages and timeouts are reported and do not. --deny overrides this key for one run, and --max-warnings bounds the warning count in addition — whichever bound is stricter wins.

The gate is a judgement on the report, not a refusal to produce one: the findings are printed either way, and only the exit code changes.

The two numbers count different things, deliberately. --deny and --max-warnings count occurrences — one dead link written in twelve files is twelve things to fix — while the summary line and summary.counts in the JSON count URLs, because one URL was requested once and earned one verdict. So a run can report 1 broken and still exceed --max-warnings 5.

ignore¶

Type: array of strings (default: [])

Regular expressions matched against each full URL. A URL matching any of them is reported as ignored and never requested.

Patterns are searched anywhere in the URL, so example\.com covers every URL on that host; anchor with ^ or $ when you want a prefix or a whole match.

The URL a pattern is matched against is the normalized one — the spelling the checker requests, not the bytes in your document. Normalization drops the #fragment, lowercases the scheme and host, and removes a default port, so a pattern written with #section, an uppercase host or an explicit :443 can never match. Write the pattern the way the report prints the URL.

[linkcheck]
ignore = [
    "^https://internal\\.example/",   # a host only your VPN can reach
    "linkedin\\.com",                 # a host that blocks every checker
]

To suppress findings for particular source files instead, use [lint.per-file-ignores] with the linkcheck.* codes — the equivalent of Sphinx’s linkcheck_exclude_documents, without a second knob to learn.

allowed_redirects¶

Type: table of string → string (default: {})

Redirects you expect, as from-pattern = to-pattern. Both sides are regular expressions searched anywhere in the URL. When a URL matching a key redirects to a URL matching that key’s value, the result is reported as working instead of as a redirect.

[linkcheck.allowed_redirects]
# Our own docs moved; the redirect is intentional and permanent.
"^https://docs\\.old\\.example/" = "^https://docs\\.new\\.example/"

A redirect that only adds a trailing slash to a bare host, drops a default port or drops a fragment is never reported, so no entry is needed for those.

timeout¶

Type: float (default: 30.0)

Seconds allowed for each HTTP request. A value that is zero, negative or not a number falls back to the default and is reported as a configuration diagnostic.

workers¶

Type: integer (default: 8)

How many URLs are checked in parallel. 0 is treated as 1.

Per-host limits apply independently: at most 2 requests to any one host at a time, and at least 250 ms between two requests to it. Raising this therefore speeds up a project that references many hosts, not one that references a few.

cache_days¶

Type: integer (default: 5)

How long a stored result stays usable:

  • > 0 — a result expires after this many days;

  • 0 — always re-check;

  • negative — never expire.

Freshness is applied when the cache is read, so changing this value does not discard anything. offline mode is the one exception: it serves expired results too, and reports how many.

offline¶

Type: boolean (default: false)

Never perform network requests: serve stored results, and report a URL with no stored result as unchecked. Useful for a network-restricted CI job with a pre-warmed cache.

Offline mode ignores cache_days and serves expired results as well as fresh ones — a stale answer beats no answer when there is no way to get a fresh one. The run says how many it did that for: the summary line reads N link(s) checked (M from cache, K expired, 0 request(s)), and --output-format json carries the same number as summary.stale. An offline run also reports the anchors it could not ask about, as N anchor(s) not checked on the same line (see the four states). A job that wants “offline, but never on expired data” should gate on that number being 0; the run itself does not refuse, because refusing is what an offline mode exists not to do.

--offline turns this on for one run. When the key is true, --refresh is refused rather than silently ignored, and the message names the override to type.

rate_limit_ceiling¶

Type: float (default: 300.0)

The longest wait, in seconds, a rate-limited host can ask for and still be waited out.

A server answering 429 Too Many Requests with a longer Retry-After than this — and a server that keeps deferring until the waits add up to more than this — makes the URL report as blocked rather than holding the run open. Both forms of Retry-After are understood: a number of seconds, and an HTTP-date.

This is the knob that makes the command safe to run unattended. A value that is zero, negative or not a number falls back to the default rather than to “wait indefinitely”.

max_redirects¶

Type: integer (default: 10)

How many redirects one URL may go through before it is reported as broken.

user_agent¶

Type: string (default: "")

The User-Agent sent with each request. Empty means ubc/<version>, which identifies the tool honestly, alongside a browser-plausible Accept header.

Some sites refuse requests from any agent they do not recognise. Setting a browser-like string here works around that; it is offered as a choice rather than done for you.

anchors¶

Type: boolean (default: true)

Added in version 0.34.0.

Whether each URL’s #fragment is verified against the anchors the page declares — see Anchors for what that means in detail.

Set it to false to check URLs alone. Nothing about a fragment is then asked: every request stays HEAD-first with no body, and no linkcheck.anchor_missing finding can be produced.

allow_private¶

Type: boolean (default: false)

Whether to check URLs pointing at addresses that are not globally routable.

By default such URLs are reported as unchecked and never requested, because a checker follows URLs written by whoever wrote the document, and those ranges include internal services and cloud metadata endpoints. Turn it on for documentation that legitimately links to an intranet.

The refused set is the private ranges, loopback, link-local (which covers 169.254.169.254, the cloud metadata endpoint on every major provider), CGNAT, the unspecified and broadcast addresses, and the RFC 5737 documentation ranges — plus, for IPv6, loopback, the unspecified address, and the IPv4-mapped and IPv4-compatible forms of all of those, so ::ffff:127.0.0.1 and ::127.0.0.1 are refused exactly as 127.0.0.1 is. The host names localhost and anything under .local are refused by name.

The check is applied to the URL you wrote and again to every redirect it follows, before each hop is requested, so a public URL that redirects into one of those ranges is refused rather than fetched. No DNS lookup is performed, deliberately — it would cost a round trip per URL and rebinding defeats it anyway — so a host name that resolves into a private range is not caught by the address rules.

The cache¶

Results are stored per project, in a single file under the project root — the directory holding your ubproject.toml, where every other ubCode cache lives, whatever your source directory is set to:

.ub_cache/linkcheck/v1/results.jsonl

Only successes and redirects are ever stored. A cached failure would hide a link that has since been fixed, which is the one way a cache can make a checker wrong rather than merely slow.

The file lives beside the index cache rather than inside it, deliberately: the index cache is keyed by your whole resolved configuration and is discarded whenever the index layout changes, and neither of those has anything to do with whether a URL is alive. Editing a configuration key therefore does not throw away a slow, politeness- limited network pass.

ubc build linkcheck cache dir      # where the file is
ubc build linkcheck cache list     # what is stored, in URL order
ubc build linkcheck cache clean    # forget everything
ubc build linkcheck cache clean https://example.test/page

A missing, unreadable, corrupt or older-version cache is a clean miss — never an error.

Wiring it into CI¶

Persist .ub_cache between runs and the second and later runs cost no network requests at all for links that have not expired:

- uses: actions/cache@v4
  with:
    path: .ub_cache
    key: ubc-linkcheck-${{ runner.os }}-${{ github.run_id }}
    restore-keys: |
      ubc-linkcheck-${{ runner.os }}-
- run: ubc build linkcheck --output-format json > linkcheck.json

The key must be unique per run and the restore-keys prefix is what makes the previous run’s cache load: actions/cache saves at the end of a job only when the exact key was not already present, so a constant key restores the first run’s file forever and never stores a newer one. With this pair, each run restores the most recent matching cache and saves the updated one under its own key.

The exit codes follow the house convention:

0

Nothing at or above the deny bar.

1

The gate tripped — your links have findings. A run refused because the configuration contradicts a flag (--refresh against [linkcheck] offline = true) also exits 1: it is a run-time refusal with its own message, not a usage error, because the configuration is not on the command line for the parser to see.

2

A usage error — the argument parser rejecting the command line itself, for example --refresh together with --offline.

“The tool could not run” — an unreadable configuration, a cache that cannot be written — is an ordinary error with its own message, never conflated with “your links are broken”.

--output-format json writes one versioned document to stdout and demotes everything else to stderr, so the stream is safe to pipe:

{
  "version": 1,
  "summary": {
    "checked": 12, "occurrences": 30, "from_cache": 9, "requests": 3,
    "stale": 0, "suppressed": 0, "unverified": 1, "not_checked": 1,
    "counts": {"anchor_missing": 1, "broken": 1, "redirect_permanent": 2,
               "working": 9}
  },
  "links": [
    {
      "url": "https://example.test/gone",
      "status": "broken",
      "http_status": 404,
      "final_url": null,
      "message": "HTTP 404 Not Found",
      "from_cache": false,
      "code": "linkcheck.broken",
      "severity": "error",
      "suppressed": false,
      "occurrences": [
        {"path": "docs/guide.rst", "line": 42, "column": 17,
         "kind": "reference", "authored": "https://example.test/gone",
         "need": null}
      ]
    },
    {
      "url": "https://example.test/manual.pdf",
      "status": "working",
      "http_status": 200,
      "final_url": null,
      "message": "HTTP 200",
      "from_cache": false,
      "code": null,
      "severity": null,
      "suppressed": false,
      "occurrences": [
        {"path": "docs/guide.rst", "line": 51, "column": 4,
         "kind": "reference",
         "authored": "https://example.test/manual.pdf#page=3",
         "need": null}
      ],
      "fragments": {
        "page=3": {
          "result": "unverified",
          "reason": "Content-Type application/pdf has no HTML anchors"
        }
      }
    }
  ]
}

fragments is present only for a link some occurrence wrote a #fragment on, and carries one entry per fragment: present, missing, unverified (the page was asked and could not answer) or not_checked (the page was never asked). summary.unverified counts the third and summary.not_checked the fourth; both keys are always present.

Two counting rules follow from linkcheck.anchor_missing being a finding about a fragment rather than about a URL:

  • a missing anchor produces its own links[] entry, whose url carries the #fragment and whose occurrences are the subset of the page’s that wrote that fragment. The page keeps its own entry and its own verdict;

  • so summary.checked and summary.occurrences exclude those entries — they count URLs requested and places in the project — while summary.counts includes them, because that tally is about findings. The two reconcile as sum(counts) - counts.anchor_missing == checked.

line and column are 1-based, and column counts UTF-8 bytes into the line rather than characters.

Either may be null, which means the position is genuinely unknown rather than that it is the top of the file. A URL that comes from a need’s own metadata carries a line and a null column, because the position the need knows is its directive’s, not the URL’s; a link whose syntax tree node carries no source location has null for both. The human output does the same thing — it prints file:line:column, file:line or just file — so a reported position is never a guess.

Fixing redirected links¶

Added in version 0.34.0.

A permanent redirect is a link that works today and is wrong: the target has moved, the old URL is a courtesy the far end can withdraw at any time, and the fix is mechanical. --fix-redirects performs it, in your source:

ubc build linkcheck --fix-redirects --dry-run   # print the plan, write nothing
ubc build linkcheck --fix-redirects             # apply it

The dry run prints one line per edit, plus a per-file count and every occurrence it will not touch, with the reason:

guide.rst:42:17  https://old.example/page -> https://new.example/page
guide.rst: 1 occurrence(s)
index.rst:8  unfixable: the line is known but not the column, so there is
             nothing to search forward from
would fix 1 occurrence(s) in 1 file(s) (1 skipped); nothing was written

What gets written is the redirect target in its normalized spelling — lowercase scheme and host, a root slash where the server sent none — not the Location header verbatim.

Only 301 and 308 are rewritten. A 302/303/307 says the move is temporary, and rewriting your source on the strength of one turns somebody’s maintenance window into a permanent edit. A redirect that an allowed_redirects rule permits is expected rather than a defect, and is left alone.

Anchors are carried across correctly. If the redirect points at one — 301 Location: /b#intro — that anchor is what gets written, because the server is telling you where the content moved to. If it does not, a #fragment you wrote is kept: a browser applies the original fragment to the redirect target, so dropping it would change where your link lands.

What is and is not fixable¶

An occurrence records where the reference starts, not where the URL inside it starts, so the rewrite finds the URL by searching forward from that position — to the end of that line, then, only if that found nothing, across the following line, and no further. It takes the only instance in whatever it searched: if the same URL appears more than once there, it cannot tell which one belongs to the link, so it declines. Everything it cannot locate that way is reported as unfixable rather than approximated. In practice that covers the overwhelming majority of real links; these are the cases it declines, and why:

  • A URL split across two lines. An embedded URI wrapped mid-URL is joined back together before the checker sees it, so the text it is looking for exists nowhere in your file as a contiguous string.

  • A Markdown reference-style link. The position is the [text][ref] use, while the bytes to change are at the [ref]: https://… definition, which can be anywhere in the file. A wider search would rewrite bytes the position does not describe.

  • A link carried by a substitution. It is reported once, at its definition, and the definition is what you edit — but the rewrite declines it, because the definition line records the substitution rather than the URL’s own column.

  • Anything with no position at all: an extlinks role expansion, an RST .. image:: URI, a sphinx_design directive argument, or a URL that exists only in a need’s metadata. There is nowhere to point at, so there is nothing to rewrite; the finding still tells you the URL and the file.

  • A link whose text is also its target, written on one line, such as ` `https://example.com/a <https://example.com/a>`__ ` or [https://example.com/a](https://example.com/a) — the URL appears twice in the same place the search looks, and the position alone cannot say which one is the link. Change one of them (or both) by hand.

    If that same link is wrapped, with the text on one line and <https://example.com/a>`__ starting the next, it is not declined: the text is then the only copy on the line the search looks at, so the text is what gets rewritten and the link keeps pointing at the old address. Running the command again fixes the link too, but the file in between displays the new address and links the old one. Check a wrapped link of that shape by hand.

  • A symlinked source file, which is refused rather than replaced.

Before writing, the run verifies that each file is still byte-for-byte the text that was indexed (a SHA-256 comparison), and that the bytes at the computed position really are the URL it is about to replace. A file that changed under the run is skipped whole, with a message asking you to re-run — and that check is made twice: once when the plan is built, and again immediately before the file is replaced, because everything else in the project is planned in between. So an edit that lands while the run is planning the rest of the project — an editor autosave, a git checkout — is never overwritten; that file is reported and left alone. A symlink is refused rather than followed: replacing it would leave you with a regular file where your link was, and the real file still unfixed. Each file is then written through a temporary file in its own directory and renamed into place, so an interrupted run cannot leave a half-written source file; the file’s permissions are preserved, and only the URL’s own bytes are touched — a byte-order mark, CRLF line endings and every character around the link are left exactly as they were. A file that cannot be written is reported and does not stop the rest of the run.

Running it twice is safe, and for most links the second run has nothing to do: the bytes at that position are already the new URL, and the run re-indexes anyway. The one shape where a second run still changes something is the wrapped text-equals-target link described above — the first run rewrites the display text, the second the link itself. Two runs converge; neither corrupts anything.

The findings a fixing run prints are the ones it measured, before the edit — the command does not re-check what it has just rewritten, and says re-run to verify instead. The exit code is likewise the one the check earned. The plan, the summary and any failures are printed whatever --output-format is in use; under json and sarif they go to stderr, so stdout stays a pipeable document.

A rewrite can be driven entirely by cached verdicts. Redirect results are cached, so --fix-redirects --offline will rewrite your source from a stored answer up to cache_days old without making a single request. That is what a network-restricted CI job with a warm cache does, and it is supported — but so you are never surprised by it, the plan marks such edits (from cache) and the JSON record sets fixes.from_cache. Pass --refresh to check before fixing.

Under --output-format json the record gains a fixes object whenever --fix-redirects was given:

{
  "fixes": {
    "planned": 1,
    "applied": 1,
    "dry_run": false,
    "from_cache": false,
    "failed": [],
    "skipped": [
      {"path": "index.rst", "line": 8, "column": null,
       "authored": "https://old.example/page",
       "replacement": "https://new.example/page",
       "reason": "no_column",
       "message": "unfixable: the line is known but not the column, so there is nothing to search forward from"}
    ]
  }
}

The reason token is stable and safe to branch on; message is the same thing as a sentence. Paths in skipped and failed are both relative to the source directory, so the two lists join. The tokens are no_position, no_column, unknown_file, unreadable, hash_unknown, hash_mismatch, not_located, ambiguous, bytes_differ, overlapping_edit, symlink, unchanged and allowed_redirect.

Anchors¶

Added in version 0.34.0.

A URL written with a #fragment makes a second claim: not only that the page is there, but that something on it is called that. ubc build linkcheck checks both, and [linkcheck] anchors turns the second one off.

What counts as an anchor¶

An element whose id matches the fragment, or an <a> whose name matches it. Matching is exact and case-sensitive — #Install does not reach id="install" in a browser either — and the percent-decoded spelling is tried as well, so #a%20b finds id="a b" and a literal %20 in an id still matches too.

#top is always present, on every page, whether or not the page declares an id="top": that is what a browser does with it.

name is accepted on <a> only, which is what the HTML standard says and a deliberate difference from Sphinx and from lychee, both of which accept it on any element. On this project’s own rendered documentation their rule finds 503 extra “anchors” — every one of them on an <input> or a <meta>, none on an <a> — which would grade a link written #viewport as working.

The four states¶

present

A candidate spelling matched something the page declares.

missing

The whole page was read and nothing matched. This is the only state that produces a linkcheck.anchor_missing finding.

unverified

The page was asked and could not answer the question. Reported as a count, never as a finding: the summary line reads N unverified anchor(s) and --output-format json carries summary.unverified plus a per-link fragments object naming each fragment, its state and — for this state — the reason.

A fragment is unverified when:

  • the page did not answer 2xx. The URL has its own verdict; its anchors do not add a second one;

  • the response is not text/html or application/xhtml+xml, or carries no Content-Type at all. A PDF answering 200 to #page=3 is a working link to a page-3 view, not a missing anchor;

  • the response arrived in a content encoding that was never requested, so the bytes are not a document at all;

  • the body was larger than the 8 MiB limit and the anchor was not in the part that was read. A truncated body can prove an anchor is there, never that it is absent;

  • the page’s bytes reached the checker in an encoding it could not decode, so the comparison would be meaningless. A page that declares its encoding is converted for us and is checked normally — this is about the pages that do not: no charset at all, one nothing recognises, or a media type that is not text/*. A body that is not valid UTF-8 declines the non-ASCII fragments only, because the tags are still ASCII so the page is read and an ASCII fragment either matches or genuinely is not there. A body containing a NUL byte — UTF-16 or UTF-32 that arrived unconverted — declines every fragment, because no tag can be read out of it at all. A page that merely carries a stray NUL (binary inside a <script>, a padded response) is treated the same way: every anchor on it is unverified rather than a possible false finding;

  • a quirk recognised the fragment as client-side state.

A fragment on a URL that was never requested — one an ignore pattern covers, one using a scheme this checker does not verify, one whose host never answered, or one served from a stored result under offline that had never been asked that anchor — is a fourth state, not_checked. It appears in the per-link fragments object with its reason, is counted separately as summary.not_checked, and the summary line reads N anchor(s) not checked beside the unverified clause — both omitted at zero. Without that clause an --offline run over a project whose links carry fragments printed no anchor-level signal at all, which reads exactly like a run that checked them. It is deliberately not counted as unverified: those URLs already have their own verdict in the report, and counting their anchors again in a second unit would both restate the same fact and make summary.unverified impossible to gate on — one ignore rule over a host you link with a fragment would pin it above zero for ever.

The rule the command holds itself to is that anchor_missing is reported only when the whole body was read and nothing matched. Everything short of that, on a page that was read, is a counted unverified, because a number you can see is a promise to come back to it and a silent skip is not.

One request per page¶

A page linked with at least one fragment is fetched with a single GET that reads its body; the HEAD is skipped outright rather than sent and thrown away. Every fragment of that page is answered from that one response, however many of its anchors your project links to. A page linked without any fragment is unaffected and still costs only headers.

One consequence is worth knowing before you turn this on, because it can change a page’s verdict rather than a fragment’s: a fragment-bearing URL goes straight to GET, with no fallback back to HEAD. A host that answers HEAD 200 but 405, 403 or 429 to a GET — bot protection that only fires on GET, or a server for which GET is the expensive method — will therefore be reported as broken, blocked or rate-limited where it previously passed. [linkcheck] anchors = false is the escape, and [linkcheck] ignore is the per-host one.

Fragments are checked against the final page of a redirect chain, and the gate is the HTTP status rather than the verdict: a URL reported as redirect_permanent still has its anchors checked, because a redirect is a finding about the URL’s spelling and not about its reachability. If a redirect’s Location carries a fragment of its own — 301 Location: /b#intro — that one wins, because the server is saying where inside the new page the content went; otherwise the fragment you wrote is re-applied to the target, which is what a browser does.

Results are stored in the result cache beside the page’s own verdict and expire with it. A cached page that is asked about a fragment it has never been asked about is re-fetched once, and one request answers every fragment.

A missing anchor is stored too, which is the one place the “failures are not cached” rule does not reach: the rule is about the page, whose own result is unaffected, and a fragment answer is exactly as time-dependent as a status. So if the target page’s owner adds the anchor, the finding can persist for up to cache_days — five days by default. --refresh, or ubc build linkcheck cache clean <url>, re-asks sooner. Fixing your own #fragment needs neither: it changes the fragment the run asks about, which the stored entry cannot answer, so the page is re-fetched on the very next run.

Quirks¶

Some fragments are not claims about the served HTML at all — they are state a browser interprets and no page declares. Checking those would report a confident missing anchor on a link that works perfectly, so they are recognised by name and reported unverified. Each entry names itself in the reason, so a decline you disagree with can be found here from the words in your own report.

strip line-number fragments

The line and line-range fragments the forges emit, each in the spelling that forge actually uses:

  • GitHub, GitLab, Gitea and Forgejo — #L12, #L12-L20, the #L5-15 shorthand, and GitHub’s column form #L12C3 / #L12C1-L18 / #L12C3-L14C9;

  • Bitbucket Cloud — #lines-12 and #lines-12:20;

  • Sourcegraph — #L12:5-14:9;

  • cgit — #n12.

Recognised on github.com, gitlab.com and bitbucket.org, and on any URL whose path carries /blob/, /-/blob/, /src/ or /tree/ — which covers self-hosted Gitea, Forgejo, GitLab, Sourcegraph and cgit, whose host names cannot be known in advance. Both halves are required, so an ordinary #L12 heading on a documentation site is still checked.

/tree/ is the exception worth knowing: it is a common path segment on ordinary sites too, so a real id="L9" under a /tree/ path is declined. The cost is a fragment reported as unverified rather than checked, never a link reported as broken.

github blob-view anchors

Any github.com/<owner>/<repo>/blob/… URL with a fragment, whatever the file type. GitHub’s file view renders content anchors in the browser, so the served page does not declare them — for markdown, and equally for the reStructuredText, AsciiDoc, Org, Textile and other markups it renders. The document is still checked. This is a known limitation rather than a permanent answer — resolving such a fragment properly means reimplementing GitHub’s own heading-slug algorithm, which is unversioned and can change without notice.

github user-content candidate

Not a decline. For any github.com host, user-content-<fragment> is added as an extra candidate spelling, because GitHub prefixes author-supplied ids and re-attaches the un-prefixed behaviour in JavaScript. It can only turn a false failure into a pass.

hashbang fragments

A fragment beginning with ! — the AJAX-crawling convention, which is pure client-side state.

Two quirks other tools carry are deliberately not here. A crates.io-specific Accept header is unnecessary: it works around a default Accept: */* that this command does not send, and a per-host header is what [linkcheck.request_headers] is for. Rewriting a YouTube URL to its thumbnail image checks a different resource, which would then be the URL offered to –fix-redirects to write into your source.

Escalating and silencing¶

A missing anchor is a warning, so it does not fail the command by default. To make it fail:

[linkcheck]
deny = "warning"

or pass --deny warning for one run. To silence the class entirely, or for particular files, use the ordinary lint filters — [lint] ignore takes linkcheck.anchor_missing like any other code.

SARIF output¶

Added in version 0.34.0.

--output-format sarif writes a SARIF 2.1.0 document to stdout — one result per occurrence, with the rule metadata, a stable fingerprint and, for every permanent redirect, a machine-applicable fix. Uploaded to GitHub code scanning, each finding appears inline on the pull request’s diff:

- run: ubc build linkcheck --output-format sarif > linkcheck.sarif
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: linkcheck.sarif

if: always() on the upload step is what makes this work, and it is the only thing needed. A step without it does not run once an earlier step has failed, so a job with a dead link would upload nothing — exactly when the annotations are worth having. With it, the check step fails the job and the annotations are still uploaded, from a single run: a second run would be a second traffic budget spent on third-party servers, even with a warm cache.

Do not add continue-on-error: true to the check step to achieve this. It does the opposite of what is wanted here: its purpose is to stop a failing step from failing the job, so the annotations would upload and CI would stay green on a dead link.

Three details are worth knowing:

  • Columns are UTF-16 code units, which is what the format requires — not the UTF-8 byte columns the JSON record carries. The conversion needs the source line, so a file that cannot be read yields a result with a line and no column rather than a wrong one.

  • Paths are relative to your repository root when one can be found (a .git directory at or above the source directory), because that is what code scanning resolves them against. With no repository, paths stay relative to the source directory and the document declares what they are relative to. A source that lives outside the source directory — through [[source.mounts]] — is emitted as an absolute file:// URI with no base, which is honest but is not something code scanning can annotate.

  • An unknown position stays unknown. A finding with a line and no column gets a region with only a line; one with neither gets a location with no region; one with no file at all — a URL from an external need source — is attached to the need by name. No position is ever invented.

Two more things to know if you combine it with --fix-redirects. The machine-applicable fixes are computed from the file as it is, without the --fix-redirects staleness check, so against a stale index the document can offer a fix the fixer itself would refuse. And when the same run also applied fixes, the document describes the state before them — which is what its findings describe — and offers no fix for an edit it has already made. A file whose write failed keeps its fixes, and so does every result of a --dry-run.

Findings silenced by [lint] are omitted from the document entirely.

Per-host settings¶

Added in version 0.34.0.

Two tables adjust how a particular host is treated.

[linkcheck.hosts."slow.example.com"]
max_concurrent = 1             # default: 2
min_interval_ms = 1000         # default: 250

[linkcheck.hosts] is keyed by host name — no scheme, no port, no path — and matched case-insensitively (ASCII). An internationalised host must be written in its punycode form (xn--bcher-kva.example), which is the form a URL is requested under; a Unicode spelling never matches. Use it for a host that is slow, that rate-limits, or that your project links to hundreds of times. max_concurrent = 0 is treated as 1, since zero would stop the host being checked rather than throttling it; min_interval_ms = 0 is allowed and means no gap at all, which is only sensible for a host you run yourself.

[linkcheck.request_headers."https://api.example.com/"]
Accept = "application/json"
Authorization = "Bearer ${EXAMPLE_TOKEN}"

[linkcheck.request_headers] is keyed by URL prefix, and the key must be an absolute http/https URL. Its host is what the headers are scoped to: a URL gets them only when its host is exactly that host and the URL starts with the prefix. A bare host name, a key with userinfo before the host (https://a@b/), a key carrying a #fragment and an empty key are each refused when the run starts, with a message naming the key.

The longest matching prefix wins, and only its headers are sent — entries do not accumulate, so a specific prefix can replace a general one’s Authorization rather than adding to it.

A header value of the form ${VARIABLE} is replaced with that environment variable when the run starts, which is how a token reaches a request without being written into a file you commit. Header names are never expanded, and neither is the prefix. A variable that is not set, a ${ that is never closed, and an empty ${} each refuse the run and name the key: the alternatives are sending the literal text to a third party, or silently omitting the header and reporting everything behind that authentication as blocked for a reason you cannot see. A header name or value HTTP does not admit — a space in a name, a newline in a value — is refused there too, rather than failing every request under the prefix with an opaque transport error.

Headers are not forwarded across a redirect to a different host. Both the host and the prefix are matched against each URL as it is requested, so a credential you wrote for one host is never sent to another — including when the first host redirects there, and including when the other host’s name merely begins with yours (api.example.com.attacker.test), which a prefix match alone would not have caught.

What is checked, and what is not¶

Checked:

  • hyperlink references and targets, in reStructuredText and Markdown alike — named, anonymous, indirect and embedded-URI forms all converge on one thing by the time the checker sees them;

  • remote image sources and the :target: an image links to;

  • remote .. video:: sources and posters;

  • external :download: targets;

  • toctree entries that name an external URL (Report an issue <https://example.test/issues>);

  • a need’s external_url, and URLs rendered by [needs.string_links] rules.

Two counting rules are worth stating, because they are the two places the number of occurrences differs from what a rendered page would suggest:

  • ``.. only::`` blocks are checked, whatever their tag expression says. The checker reads your source rather than one build’s output, and a link that only a pre-release build renders is still a link that will rot. So a URL written inside an .. only:: block is checked even for a build that would drop the block.

  • A link carried by a substitution counts once, at the substitution’s definition — which is the one place you would edit it — however many times the substitution is used.

Not checked in this release:

  • Absolute URLs that came from an intersphinx inventory. Those are checked by whichever project published the inventory, not by the one consuming it.

  • Links inside ``raw`` blocks, which are opaque text this project does not parse.

  • Local file links, which the reference resolver already owns — this command is about external URLs.

Deliberately not built:

  • Anything that circumvents a bot-block. A challenge page is reported as blocked; use ignore for a host that will never answer a checker, or allow_private for one only your network can reach.

  • Crawling. Authored URLs are verified; nothing is discovered.

  • ``robots.txt`` consultation. A specific URL a human already published a link to is verified once, which is not crawling. The obligations that do bind — an honest User-Agent, 429 compliance and per-host caps — are all kept.

Next
Parsing
Previous
Intersphinx
Copyright © 2026, team useblocks
Made with Sphinx and @pradyunsg's Furo
On this page
  • Link checking
    • Minimal example
    • Running it
    • Verdict classes
    • Options
      • deny
      • ignore
      • allowed_redirects
      • timeout
      • workers
      • cache_days
      • offline
      • rate_limit_ceiling
      • max_redirects
      • user_agent
      • anchors
      • allow_private
    • The cache
    • Wiring it into CI
    • Fixing redirected links
      • What is and is not fixable
    • Anchors
      • What counts as an anchor
      • The four states
      • One request per page
      • Quirks
      • Escalating and silencing
    • SARIF output
    • Per-host settings
    • What is checked, and what is not