Skip to content

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).

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.
  • _EXEMPTIONS in scripts/check_doc_conventions.py — (page, rule) pairs.

Every entry needs a comment saying why. There are exactly three acceptable justifications:

  1. Placeholder. The name is one the reader supplies (MyStrategy()), not one the framework ships.
  2. Unmodelled surface. The symbol is real but lives somewhere the gate does not read (the QuickJS globals).
  3. 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:

```python
```

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.