docs: make API reference rendering independent of page order

Merged
MA
3mo ago
+146-35

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: true on 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_modules emission from gen_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_url is promoted to page_url so 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_modules from 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.

AI Disclaimer