The link checker¶
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.
pkgs/docs-links.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¶
| Where | What counts |
|---|---|
code (.nix, .lua, .qml, .sh, .toml, .yaml, .yml) |
a bare docs/…md path, which is the form the headers use |
| markdown | only a real ](target) link, resolved against the file's own directory |
markdown outside docs/history/ |
a repo path quoted in prose, like `system/hardware/gpu.nix` |
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 system/, 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 writePython3Bin¶
The same reasoning as writeShellApplication in rule 7: the logic lives in the BUILD, and the
build is what lints it. writeShellApplication gives shellcheck; writePython3Bin gives flake8,
and it refused to build until the spacing was right, which is exactly the point of putting it
there.
flakeIgnore = [ "E501" ] because this repo's line length is 100 and flake8 defaults to 79. That
is the only rule relaxed.
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.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 router/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.