Documentation gates¶
Generated pages (the indicator catalog, the Python API index, llms.txt)
have been gated against their source of truth for a long time. Hand-written
prose was not, and a full-tree audit found what that costs: pages
documenting functions that never existed, snippets that raise on the first
line, wrong struct sizes, orphan pages nobody could navigate to, and links
to files that were renamed years ago. Every generated-doc gate was green the
whole time — they simply do not read prose.
The six gates below close that hole. All of them are plain python3
scripts, no network and no site build; one of them shells out to a C++
compiler for -fsyntax-only, the rest run in a few seconds end to end.
Run them locally¶
for gate in symbols cpp_examples examples nav links conventions; do
python3 "scripts/check_doc_$gate.py" || break
done
Each script also takes --help and --quiet, prints ::error:: lines that
GitHub Actions turns into inline PR annotations, and exits non-zero on the
first real problem.
The gates¶
check_doc_symbols.py — the symbol must exist¶
Extracts every API-looking reference from docs/**/*.md and resolves it
against the real binding surface:
| Language | Reference forms | Surface of truth |
|---|---|---|
| Python | package attribute access, from imports, bare Name(...) calls nothing binds, attribute access on a variable whose class the snippet reveals |
python/lrvx/_lrvx/__init__.pyi, python/lrvx/__init__.pyi, python/lrvx/*.py |
| Node | package attribute access, new expressions, destructured package imports, in js / javascript / ts blocks |
node/index.d.ts |
| Codon | from lrvx.<module> import X in Codon blocks |
codon/lrvx/<module>.codon |
Blocks are classified by fence language, by the enclosing === "..." tab
label, and by page path — a Codon snippet tagged python inside a Codon tab
is still checked as Codon. QuickJS pages are skipped: there the namespace is
the embedded runtime's globals, which this gate does not model.
Prose is checked leniently — an inline-code reference only fails when the name exists in no binding surface.
This gate is the one that catches an entire invented API: a page-length
walkthrough of a function that was never bound. It does not catch a
signature that changed underneath an existing, correctly-spelled name —
see check_doc_cpp_examples.py below for the one place that gap is
closed, and "The rule for new APIs" for what is still open everywhere
else.
check_doc_cpp_examples.py — the C++ example must still compile¶
Three reference pages once survived a real signature change (two fields
and a method moved to std::optional) with every doc gate green,
because nothing fed the docs' C++ prose to a compiler. This gate
extracts every fenced ``cpp block that is a full, self-containedclass ... : public Strategy { ... }definition — the "worked example"
pattern used throughoutdocs/reference/api/and the tutorials — wraps
it with the real project headers, and compiles it with-fsyntax-only -std=gnu++2b`. A worked example calling a method whose
signature moved fails here instead of shipping silently.
Scope, stated plainly in the script's own docstring too: this is not
full signature verification. Most cpp fenced blocks are fragments — a
lone field, a single method signature next to a paragraph of prose —
and compiling a fragment in isolation only proves the types it names
exist, not that they match the real member (a fragment redeclaring
std::optional<Price> avgEntryPrice compiles whether or not the real
field still has that type). Only full compilable examples are covered;
check_doc_symbols.py above is still all that touches everything else.
check_doc_examples.py — the example must run¶
Executes every docs/examples/*.py and fails on a non-zero exit, then
syntax-checks every docs/examples/*.js with node --check (skipped with a
notice when node is absent).
Examples that cannot run in CI are listed in SKIP_PY at the top of the
script, each with its reason. The only entry today is the live ccxt example,
which needs network and credentials.
The compiled extension is not present in the docs-only CI job, so there the
gate degrades to a syntax check and says so. The real execution coverage
comes from the linux-gcc job, which runs the same script with
--require-runtime after building the bindings.
This is what makes the --8<-- include pattern load-bearing: a page that
includes a real file instead of pasting a snippet inherits a red build the
moment that file rots. A pasted snippet inherits nothing.
check_doc_nav.py — the page must be reachable¶
Every docs/**/*.md must appear in the mkdocs.yml nav, and every nav entry
must point at a file that exists. The theme enables navigation.prune, so a
page missing from nav is built but unreachable: no sidebar entry, no
breadcrumb, no next/previous link. Only site search finds it. The audit found
28 pages in that state.
mkdocs.yml carries a !!python/name: tag for the mermaid fence, so the
gate parses it with a tag-tolerant loader (and falls back to a regex scan of
the nav block when PyYAML is missing).
check_doc_links.py — the link must resolve¶
Validates relative .md links, repo-relative links out of docs/, and
#anchor fragments. Anchor ids are reproduced the way python-markdown's
toc extension builds them, including {#custom-id} overrides and the
_N suffix on duplicate headings.
C++ lambda captures ([&](const Order& o)) are indistinguishable from
Markdown links by shape, so a target only counts as a link when it looks
like a path: it contains .md, contains /, or starts with #.
check_doc_conventions.py — the conventions the docs keep breaking¶
| Rule | Fails on | Why |
|---|---|---|
PY_IMPORT |
the FLOX-era Python names: flox-py, flox_py, import lrvx as lrvx, import lrvx as flox |
the distribution and the module are both lrvx, imported as plain import lrvx |
NODE_PKG |
the FLOX-era npm package @flox-foundation/flox |
the published package is lrvx: require('@lrvx/lrvx') |
QUICKJS_REQ |
a module-loader call on a docs/reference/quickjs/** page |
the embedded runtime injects classes as globals; prose stating that absence is allowed |
CMAKE_FLAG |
a FLOX-era option name (FLOX_BUILD_*, FLOX_ENABLE_*, FLOX_NATIVE, ...) inside a runnable shell block |
the options are LRVX_*; CMake does not recognise the old names and builds without them. The mapping table in how-to/migrate-from-flox.md is prose, so it is not flagged |
EMOJI |
any emoji | the project forbids them. ✓ and ✗ are table markers, not emoji |
Exemptions live in _EXEMPTIONS in the script, keyed by (page, rule),
each with a reason. A stale exemption — one whose violation is gone — fails
the gate too, so the list cannot rot.
Allowlists¶
Two gates carry an allowlist because both have unavoidable false positives:
scripts/doc_symbols_allow.txt— one entry per line, either a bare symbol (Foo) or page-scoped (docs/how-to/x.md:Foo). Prefer page-scoped, so the same name used wrongly on a new page still fails._EXEMPTIONSinscripts/check_doc_conventions.py—(page, rule)pairs.
Every entry needs a comment saying why. There are exactly three acceptable justifications:
- Placeholder. The name is one the reader supplies (
MyStrategy()), not one the framework ships. - Unmodelled surface. The symbol is real but lives somewhere the gate does not read (the QuickJS globals).
- A real defect in a file the current change cannot touch. Mark it
TODO:and name the defect and its owner. These are debt, not policy — delete the entry with the fix.
An allowlist entry with no reason, or with a reason that boils down to "the gate is annoying", is a request to re-introduce the exact class of defect the audit found. Reviewers should treat one as a code change, not a config tweak.
The rule for new APIs¶
If you add a public API, the prose that documents it must be an executable example, or it will not be checked.
A snippet pasted into Markdown is checked for symbol existence only. That
catches a name that never existed; it cannot catch a wrong argument order, a
renamed keyword, a changed return shape, or a struct whose size the page
states in bytes. check_doc_cpp_examples.py narrows that gap for one
shape — a full, self-contained C++ Strategy subclass — by compiling it
against the real headers; everything else, and every other language, is
still symbol-existence-only. The only mechanism that catches all of it is
a file under docs/examples/ that CI runs, included in the page:
The snippet ratchet in scripts/check_doc_snippets.py enforces the
direction of travel: CI pins a floor on the number of --8<-- includes and
that floor only ever goes up. Migrating a snippet to a runnable file is
always a net win; pasting a new inline block for a language the ratchet
lints requires an allowlist entry in docs/.snippet-allowlist.txt.
Where they run¶
All six run in the verify-docs-current job in .github/workflows/ci.yml,
each as its own named step so a failure names itself in the PR checks. That
job gates the build matrix, so a docs defect fails fast instead of after ten
minutes of compilation. check_doc_examples.py runs a second time in
linux-gcc with --require-runtime, where the bindings exist and the
examples actually execute. check_doc_cpp_examples.py needs only a C++
compiler and the checked-out headers, so it runs the same way in both
places without a full cmake --build.