dotfiles
Repo

The link checker

Edit on GitHub

Its sibling is dead-config.md: this one asks "does every pointer RESOLVE?", that one asks "is anything DECLARED and never used?". Two questions, two tools, one checks.repo-audit running both.

tools/docs-links/package.nix, wired into checks so it runs on nix flake check and therefore in the CI too. Run it by hand with nix run .#docs-links.

Why it exists

Every module carries a 2-line header ending in a pointer at one of these pages, and the pages cross-link each other. As of 16/08/2026 that is 274 references: 128 pointers from code across 92 files, plus the links inside docs/.

Nothing verified any of them. Rule 16 says a note that stops being true is a bug, and rule 2 made the pointer the ONLY path from a module to its reasoning, so a broken pointer silently undoes both: the module still looks documented, and the documentation is unreachable.

It caught a real one on its first run: docs/rules.md still pointed at docs/regras.md, a leftover the en-US migration of 15/08/2026 missed. It also made the folder reorganisation safe, which is what it was written for: moving 51 pages rewrote 128 pointers and 113 links, and the only way to know that landed was to check all 274 afterwards.

What it checks, and what it deliberately does not

WhereWhat counts
code (.nix, .lua, .qml, .sh, .toml, .yaml, .yml, .ts, .tsx, .mjs)a bare docs/…md path, which is the form the headers use
markdownonly a real ](target) link, resolved against the file's own directory
markdown outside docs/history/a repo path quoted in prose, like `modules/nixos/hardware/intel-arc-b580.nix`
a markdown link LEAVING docs/the target must ALSO be git-tracked, because the site publishes it as a blob URL
a .nix module under modules/nixos/ or modules/home/ (not default.nix)its header: at most 2 comment lines, with a docs/ pointer or a plain "No note"

The header check: rule 2, enforced (30/09/2026)

Rule 2 caps a module header at 2 lines and makes it point at the note that holds the reasoning, and until this day only a reader enforced it. MEASURED when it was written: 21 of the modules had no pointer in their first two lines, and four of those had headers of 3, 4 and 6 lines, with the pointer pushed down to the third. All 21 were rewritten, each pointing at the note that already covered it, or saying "No note" where nothing does (a module whose two lines are the whole story). MEASURED in the other direction with two sentinels: a 3-line header and a pointerless one both failed, and the tree passed.

Existing is not enough once the docs are a site (19/09/2026)

A link from a page to the module it documents is rewritten into a GitHub blob URL when site.md builds the site, because the site serves nothing outside docs/. That moves the goalposts: a target that exists ON DISK resolves here and 404s there, since GitHub can only serve what was pushed.

So a link whose target leaves docs/ is checked against git ls-files as well, which the checker was already holding to pick its inputs. At 921 references, none of them is untracked today, so this catches nothing right now and that is the point: the gap opens the day somebody links a page at a file they have not added yet, and the failure would appear on the published site rather than in the working tree.

The prose-path check, and why history is exempt

A backticked path is the most common way these docs point at a module, and nothing was checking it. Added 16/08/2026, it raised the count from 336 references to 531 and found three stale ones on the first run. docs/guides/bios-ex-b560m-v5.md still pointed at a gpu.nix and a hardware.nix directly under modules/nixos/, from before the reorganisation into categories, and docs/ideas.md referred to a ddc.nix that never survived: the DDC brightness curve was built and REVERTED.

Note that this paragraph cannot QUOTE those paths, because the check would flag its own example. That is the check working, not a limitation: a dead path in a doc that describes the present is exactly what it exists to catch, and prose about a dead path reads better without the backticks anyway.

docs/history/ is exempt on purpose. It is a diary, it names files that were deleted deliberately, and editing it to keep paths alive would stop it being evidence. That is the same reason README.md gives for history being append-only while notes are kept current.

A path belonging to SOMEBODY ELSE'S repo is written Repo:path/to/file, and the regex refuses to match after a colon. Two Foundry paths in the impermanence item looked exactly like local ones (hosts/common/…) and would have been permanent false positives otherwise, so they got the prefix and now say what they are.

A bare path inside markdown is PROSE, not a pointer, and that distinction is not pedantry: the first version checked those too and flagged docs/rules.md for the sentence saying that the old pt-BR filename "became" the current one. That sentence is history and it is correct. A file that names its own past cannot be a broken link.

Out of scope on purpose: http(s) targets (that is a network check, not a repo check), #anchors (a heading rename is a different class of drift, and matching them would mean parsing markdown rather than scanning it), and a placeholder like docs/notes/<module>.md, which documents the CONVENTION and names no file.

Why writeShellApplication

The same reasoning as rule 7: the logic lives in the BUILD, and the build is what lints it, so shellcheck refuses a wrapper that would misbehave. Until 22/10/2026 it was writePython3Bin with flake8; it became shell in phase A of languages (rule 24).

What does the work is rg: one -o pass per check over the tracked files, --sort path so the order is the tree's, plus ONE realpath -sm call for every markdown link (-s keeps symlinks as written, the way the Python's normpath did). Nothing spawns a process per item, which is what keeps it at 0.14s; a first draft with an rg per module header took 1.2s. MEASURED against the Python version: the same 1809 references on the tree, and the same 7 findings of 18 on a fixture holding one case of each kind (pointer, prose path, link, untracked target, both header faults).

The one thing to know before editing it

It walks git ls-files, so an untracked file is invisible to it. That is deliberate (the check should see what the repo ships, not what is lying around in the working tree), but it means a note you created and did not git add will read as a broken pointer, and the fix is git add, not the checker.

The external half: lychee, and why it can NEVER be a gate hook

docs-links owns the pointers that stay INSIDE the repo. This owns the ones that leave it, and the two reasons it cannot sit in the gate are structural, not a preference: a Nix build sandbox has NO network, so the check would fail by construction, and a 429 from somebody's rate limiter is not a defect in this repo.

So the lychee hook is declared at the manual stage. It lives in flake/checks.nix next to every other linter, which keeps one definition of the linter set; the gate skips it because pre-commit run --all-files only runs pre-commit-stage hooks; and the canary workflow runs it weekly with pre-commit run --hook-stage manual lychee --all-files. Running it by hand is the same command inside the devShell.

Markdown ONLY, and that is a measurement, not a taste. Over the .md: 15 external links, 278 checks, 0 errors, with no config file and no exclude list at all. Over EVERY tracked file: 17 errors, every one of them false, and they name themselves. The DoH endpoints in hosts/cudy-wr3000/uci/ answer 400 to a GET carrying no DNS query. The loopback and the LAN address are this machine talking to itself, which no runner can reach. CurseForge answers 403 to anything that is not a browser, the same bot wall that already keeps its download URL unversioned.

A URL in a code comment is a CITATION, not a reference a reader clicks, and an exclude list long enough to silence those is precisely the "lint you learn to ignore" that flake.md argues against for the two disabled statix rules. If a .md ever gains a LAN URL, the fix is one exclude in a lychee config, not turning the check off.

A red has to survive three tries, two minutes apart, in both canary steps. The two reds before that rule were both a server having a bad minute: a 522 from phoronix.com on 21/09/2026 (the Cloudflare edge timing out on the origin) and a 503 from github.com itself on 27/09/2026, each link answering 200 when checked by hand. lychee's own --max-retries does NOT cover that: MEASURED against a local server that always answers 503, lychee 0.24.1 sent exactly ONE request with --max-retries 4, so it retries network errors and never a 5xx. Hence the loop in the workflow, which reruns the whole check (about 15 s) and fails only on the third red. Accepting 5xx instead would have been the cheap fix and the wrong one, since a server that is really gone answers 5xx too. A link that is dead three times in four minutes is dead.