Skip to content

docs: make API reference rendering independent of page order#3107

Merged
maxisbey merged 1 commit into
mainfrom
docs/order-independent-alias-resolution
Jul 16, 2026
Merged

docs: make API reference rendering independent of page order#3107
maxisbey merged 1 commit into
mainfrom
docs/order-independent-alias-resolution

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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 (mcpmcp_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

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.
@maxisbey maxisbey marked this pull request as ready for review July 16, 2026 17:26
@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3107.mcp-python-docs.pages.dev
Deployment https://a397dc40.mcp-python-docs.pages.dev
Commit e3d9a27
Triggered by @maxisbey
Updated 2026-07-16 17:27:14 UTC

@maxisbey maxisbey merged commit 497f7af into main Jul 16, 2026
37 checks passed
@maxisbey maxisbey deleted the docs/order-independent-alias-resolution branch July 16, 2026 17:29

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — docs build tooling only, no runtime or public API changes.

What was reviewed:

  • The load_external_modules: true config change and the removal of the now-dead preload_modules emission in gen_ref_pages.py — no leftover references to either mechanism remain anywhere in the tree.
  • The _page_urlpage_url rename in llms_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 in build_config.py and 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_urlpage_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant