docs: make API reference rendering independent of page order
Every checks / docs job on main and on PRs has been failing since 2026-07-15/16 with AliasResolutionError: 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 ::: module blocks accumulates in filesystem-dependent order. A cross-package re-export (mcp → mcp_types) resolves only if some mcp_types page happened to be collected before docs/api/mcp/index.md renders.
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 on main.
The existing preload_modules: [mcp_types] hint on the generated mcp index page didn't help: mkdocstrings-python only honors preload_modules when the page's root package is not yet collected, and any ::: mcp.<submodule> page collected earlier loads the whole mcp package, silently disarming the hint. It was already dead on machines where the build passed — those passed purely because mcp_types pages happened to render first.
Sorting the walk wouldn't fix this — sorted order (mcp/cli/… < mcp/index.md, mcp_types last) 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.- Delete the per-page
preload_modulesemission fromgen_ref_pages.py— the mechanism whose silent-skip semantics caused this. - New gauntlet step
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:griffe._internal.exceptions.AliasResolutionError: Could not resolve alias mcp.ClientResult pointing at mcp_types.ClientResult (in src/mcp/__init__.py:1) check_render_order: api/mcp/index.md failed at position 105/111 of the 'mcp'-first order — API rendering depends on page order (see mkdocs.yml `load_external_modules`)
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
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to change)
- Documentation update
Checklist
- I have read the MCP Documentation
- My code follows the repository's style guidelines
- New and existing tests pass locally
- I have added appropriate error handling
- I have added or updated documentation as needed
Additional context
Upstream reports worth filing separately: Zensical's unsorted page walk makes builds environment-dependent; mkdocstrings-python silently ignores preload_modules when 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.