# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0
"""Stage-1 ``xref`` builder for the two-stage documentation build.
Cross-referenced documents form a dependency cycle: each doc's HTML needs the
*indexes* (``objects.inv`` / ``needs.json`` / Doxygen tag files) of the docs it
links to. Those indexes depend only on a doc's own content, so the build is
split in two:
* stage 1 — every doc emits just its index (this builder), no HTML;
* stage 2 — every doc builds HTML with all indexes present, so cross
references resolve.
This builder subclasses Sphinx's ``dummy`` builder: it reads/parses the whole
project (populating the environment) but writes no HTML. In ``finish()`` it
dumps ``objects.inv`` for intersphinx. ``needs.json`` is handled separately by
sphinx-needs itself: when ``needs_build_json = True`` its ``build-finished``
hook writes ``needs.json`` into the same output directory regardless of the
active builder — so a sphinx-needs doc gets both artifacts from one xref build.
Stage 2 uses a SEPARATE doctree cache (``-d``) and parses for itself, once every
peer index exists. Sharing one cache is what this build system used to do, and it
was wrong: a role that resolves at parse time — ``:external+<inv>:`` is one — was
resolved during stage 1 against inventories that did not exist yet, and stage 2,
reusing that parse, wrote the empty result out. See the doctree comment in
``zdocs/cmake/sphinx.cmake``.
"""
from __future__ import annotations
import os
from sphinx.builders.dummy import DummyBuilder
from sphinx.util.inventory import InventoryFile
[docs]
class XrefBuilder(DummyBuilder):
name = "xref"
epilog = "Cross-reference index written to %(outdir)s."
[docs]
def get_target_uri(self, docname, typ=None):
# Inventory URIs must match what the html builder would emit, so links
# resolved against this inventory in stage 2 point at the real pages.
return docname + ".html"
[docs]
def finish(self):
InventoryFile.dump(os.path.join(self.outdir, "objects.inv"), self.env, self)
#: Warnings that are guaranteed during stage 1 and mean nothing there.
#:
#: `intersphinx.external` is emitted by IntersphinxRole once per reference whose
#: inventory is not loaded. At stage 1 that is every cross-document reference in
#: the project, because the inventories ARE this stage's output — no build order
#: could have produced them first, and clearing the mapping below guarantees the
#: role finds nothing even where a stale one happens to exist on disk.
#:
#: Suppressed only for the `xref` builder. Stage 2 keeps the warning, and there
#: it is a real signal: an inventory missing once every document has published
#: one means a reference to a document that does not exist.
#: `needs.link_outgoing` and `needs.link_ref` fire for every reference to a need
#: defined in ANOTHER document. Those arrive via `needs_external_needs`, whose
#: entries docrefs gates on the target's needs.json existing when conf.py is
#: evaluated — on a cold stage 1 none of them do, because that file is this
#: stage's own output. The links are recorded in the export regardless (the field
#: holds the id, resolved or not), so stage 1's deliverable is unaffected.
#:
#: Stage 2 keeps both, and there a genuinely unknown id — a typo, a deleted
#: need — is reported normally.
#:
#: `needs.external_link_outgoing` is the same defect with one more hop. It fires
#: for a link whose SOURCE is itself an imported need: the peer that defined it
#: published its needs.json early enough to be imported, but the peer that its
#: link points AT did not. Whether it fires at all therefore depends on the
#: order stage 1 happened to build in, which is exactly the property that makes
#: it useless as a signal here.
#:
#: `sn_schema_warning.network_missing_target` is sphinx-needs' schema layer
#: reaching the same conclusion from the other side: it walks each need's link
#: network and reports a link whose target is not in the build. On a cold stage 1
#: that is every cross-document link, for the gating reason above.
#:
#: Scoped to that ONE rule, not to `sn_schema_warning` as a whole, and the
#: distinction is load-bearing: `network_local_fail` — a link that resolves to a
#: need of the WRONG type, which is what a `*-links-network` schema exists to
#: catch — needs its target PRESENT in order to fire at all, so it is never a
#: casualty of the gating and must keep reporting in both stages. Nor is
#: `sn_schema_violation.*` (error severity) suppressed.
_EXPECTED_STAGE_ONE_WARNINGS = [
"intersphinx.external",
"needs.link_outgoing",
"needs.link_ref",
"needs.external_link_outgoing",
"sn_schema_warning.network_missing_target",
]
def _quieten_xref_stage(app):
"""Stage 1 only needs to emit *this* document's own objects.inv/needs.json
— cross-document references get resolved anyway (and discarded, since
write_doc() above is a no-op), but that's wasted work, not something this
stage depends on. sphinx.ext.intersphinx's own builder-inited handler
(load_mappings, default priority 500) eagerly fetches EVERY sibling's
objects.inv regardless of whether this doc's content references it at
all — none of those siblings exist yet at stage 1, so that's the
"missing objects.inv" warning firing once per sibling per doc. Clearing
intersphinx_mapping here (priority 100, so this runs before
load_mappings on the same event) skips that fetch entirely.
Stage 2 is unaffected: BuildEnvironment.get_and_resolve_doctree() always
re-resolves a fresh copy of each doctree per builder invocation (see
Builder._write_serial -> _write_docname), so it does its own independent
resolution pass against the by-then-complete sibling inventories,
regardless of what stage 1 did or didn't manage to resolve.
SIDE EFFECT, worth knowing before touching this: ``intersphinx_mapping`` is
declared with ``rebuild='env'``, so emptying it here makes stage 2 — which
sees it populated again — count as a config change and re-read every
document. That happens to prevent D10 by itself, independently of the split
doctree caches in ``cmake/sphinx.cmake``; disabling both is what it takes to
reproduce the defect. This function is still only an optimisation and must
not be treated as the fix: the caches are.
"""
if app.builder.name != "xref":
return
app.config.intersphinx_mapping = {}
# Silence the warnings that clearing the mapping guarantees. Sphinx's
# WarningSuppressor reads app.config.suppress_warnings at EMIT time, so
# extending it here (builder-inited) applies to the whole run — verified
# against sphinx.util.logging rather than assumed, since a config read once
# at startup would have made this silently do nothing.
#
# Extends rather than replaces: a consumer may have its own suppressions and
# the engine has no business dropping them.
app.config.suppress_warnings = [
*app.config.suppress_warnings,
*_EXPECTED_STAGE_ONE_WARNINGS,
]
def setup(app):
app.add_builder(XrefBuilder)
app.connect("builder-inited", _quieten_xref_stage, priority=100)
return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True}