docs: make API reference rendering independent of page order#3107
Conversation
Zensical discovers pages with an unsorted directory walk and renders them all through one shared mkdocstrings handler, so the griffe collection that resolves cross-package re-exports (mcp -> mcp_types) accumulates in filesystem-dependent order. A GitHub runner-image update reshuffled readdir order and every CI docs build started failing with AliasResolutionError. The per-page preload_modules hint on the mcp index page could not help: mkdocstrings-python silently ignores it once the page's root package is already collected, which any earlier `::: mcp.<submodule>` page guarantees. Chase exported aliases at load time instead (load_external_modules: true), drop the dead per-page preload mechanism, and add a gauntlet step that renders the API pages in worst-case orders so an order-dependence regression fails on every machine instead of only on unlucky filesystems.
📚 Documentation preview
|
There was a problem hiding this comment.
LGTM — docs build tooling only, no runtime or public API changes.
What was reviewed:
- The
load_external_modules: trueconfig change and the removal of the now-deadpreload_modulesemission ingen_ref_pages.py— no leftover references to either mechanism remain anywhere in the tree. - The
_page_url→page_urlrename inllms_txt.py— all three call sites updated, no stale references. check_render_order.py's sibling-import pattern and top-level exception handler — both match the established conventions inbuild_config.pyand the repo's exception-handling policy.
Extended reasoning...
Overview
This PR touches only docs build tooling: a one-option mkdocstrings config change in mkdocs.yml (load_external_modules: true), removal of the per-page preload_modules emission from scripts/docs/gen_ref_pages.py (with a docstring update explaining the new invariant), a new ~110-line CI gauntlet script scripts/docs/check_render_order.py wired into scripts/docs/build.sh, a private→public rename in scripts/docs/llms_txt.py so the new script can reuse the page-URL mapping, and a CI workflow comment update. No library code under src/ is modified.
Security risks
None. The change is confined to the documentation build pipeline; it does not touch auth, crypto, permissions, network-facing code, or anything that ships in the published package. The new script only reads files under docs/api/ produced by the repo's own generator and renders them locally.
Level of scrutiny
Low-to-moderate. This is build tooling, not production code, and the entire change is exercised end-to-end by the checks / docs CI job on this very PR — the new check runs on every build, so a mistake in it fails loudly rather than silently. The PR fixes a real, well-diagnosed CI breakage (order-dependent alias resolution that flipped when a runner-image update reshuffled readdir order), and the description documents both a positive reproduction and a negative test of the new check. The riskiest aspect is that check_render_order.py imports Zensical internals (zensical.compat.mkdocstrings, zensical.markdown.render), but the toolchain is lock-pinned and any upstream break would surface as a loud CI failure, not a silent regression.
Other factors
I verified the _page_url → page_url rename left no stale references, that no preload_modules references remain anywhere, and that the sibling-import pattern and top-level except Exception handler in the new script follow the repo's established conventions (build_config.py uses the same import pattern; AGENTS.md explicitly permits top-level handlers). The bug hunting system found no issues. The PR timeline has no outstanding reviewer comments. Worst-case blast radius is a docs build failure, which CI catches immediately.
Every
checks / docsjob onmainand on PRs has been failing since 2026-07-15/16 withAliasResolutionError: Could not resolve alias mcp.ClientResult pointing at mcp_types.ClientResult. This makes the docs build order-independent so it cannot recur, and adds a gauntlet check that proves it.Motivation and Context
No repo change caused the failures. The docs build has been order-dependent since the Zensical switch (#3073): Zensical discovers pages with an unsorted directory walk and renders them all through one shared mkdocstrings handler, so the griffe collection that resolves
::: moduleblocks accumulates in filesystem-dependent order. A cross-package re-export (mcp→mcp_types) resolves only if somemcp_typespage happened to be collected beforedocs/api/mcp/index.mdrenders.The GitHub runner image update
20260705.232.1 → 20260714.240.1(rolled out Jul 15–16) reshuffled readdir order on the runners and flipped every job from the lucky order to the unlucky one — perfect correlation across 9 jobs, including a fail/pass pair 48 minutes apart on the same branch (fail on the new image, pass on the old), and #3095's own docs check passing on essentially the same tree that then failed onmain.The existing
preload_modules: [mcp_types]hint on the generatedmcpindex page didn't help: mkdocstrings-python only honorspreload_moduleswhen the page's root package is not yet collected, and any::: mcp.<submodule>page collected earlier loads the wholemcppackage, silently disarming the hint. It was already dead on machines where the build passed — those passed purely becausemcp_typespages happened to render first.Sorting the walk wouldn't fix this — sorted order (
mcp/cli/… < mcp/index.md,mcp_typeslast) is itself a failing order. The correct invariant is order-independence:load_external_modules: trueon the mkdocstrings python handler: when a package is collected, griffe chases every exported alias into the package it points at. Whichever page wins the filesystem race, the first collect of any root loads its cross-package targets before anything can render. The loaded set is unchanged today (mcp,mcp_types) and build time is unchanged.preload_modulesemission fromgen_ref_pages.py— the mechanism whose silent-skip semantics caused this.scripts/docs/check_render_order.py: renders each package's API pages through Zensical's real render path in the two hostile orders of mkdocstrings' first-collect asymmetry (subpages first with the index last, and the index alone), from a fresh handler each pass. A regular build only ever exercises one arbitrary order; this step makes an order-dependence regression fail on every machine instead of only on unlucky filesystems. (llms_txt.py's_page_urlis promoted topage_urlso the check reuses the one page-URL mapping.)How Has This Been Tested?
With the exact locked toolchain (zensical 0.0.50, mkdocstrings 1.0.4, mkdocstrings-python 2.0.5, griffelib 2.1.0) on CPython 3.12:
Replaying CI's failing page order without the fix reproduces the exact CI error; with the fix it passes, as do shuffled orders.
zensical build --strict:No issues found, ~16s (unchanged).check_render_order.py: all four passes green ('mcp' index-last,'mcp' index-alone,'mcp_types' index-last,'mcp_types' index-alone).Negative test — removing
load_external_modulesfrom the generated config makes the new check exit 1 with the original error and a pointer to the fix:The docs job gains roughly one extra build's worth of rendering (each API page re-rendered once, plus each package index once more).
Breaking Changes
None — build tooling only; no public API or published docs content changes.
Types of changes
Checklist
Additional context
Upstream reports worth filing separately: Zensical's unsorted page walk makes builds environment-dependent; mkdocstrings-python silently ignores
preload_moduleswhen the root is already collected; the flat (group_by_category: false) children template resolves aliases unguarded, turning an unresolved alias into a build crash instead of a skipped member.AI Disclaimer