Scripts
Standalone command-line tools, run directly with python3 — see
the companion manual’s own task-facing page for how they are invoked. Source:
scripts/.
docrefs
Central cross-document reference registry — read by every conf.py and by
add_docs_from_registry(), add_doxygen_target()
and add_sphinx_target() alike.
Central cross-document reference registry for the safety documentation set.
All inter-document links are derived from a single registry file,
doc/documents.yaml, so each conf.py only has to declare its own
identity (implicitly, via its build output directory) and pick up the derived
structures:
import docrefs
refs = docrefs.load(registry=DOC_BASE / "doc" / "documents.yaml")
html_context = {"reference_groups": refs.reference_groups}
intersphinx_mapping = refs.intersphinx_mapping # sphinx docs
needs_external_needs = refs.needs_external_needs # sphinx-needs docs
version_scope = refs.version_scope # this doc's git-tag scope
# doxylink = refs.doxylink # requires sphinxcontrib-doxylink
reference_groups, intersphinx_mapping and doxylink are emitted
unconditionally for every other document (no filesystem probing). This
determinism matters for the two-stage build (see xref_builder.py): the
config a document produces is then identical in stage 1 (xref index) and
stage 2 (html), so Sphinx keeps the shared doctree cache valid and the html
build skips re-parsing. A target whose inventory/tag does not exist yet (e.g.
during stage 1) just produces a warning and resolves once stage 2 runs.
needs_external_needs is the exception: sphinx-needs raises on a missing
external json_path (intersphinx and doxylink only warn), so it is gated on
the target’s needs.json actually existing. A sphinx-needs document’s config
therefore differs between stages until every peer index is built, so it re-reads
in stage 2 — an acceptable cost limited to the sphinx-needs documents.
- docrefs.DOXYGEN_TAGFILE = 'doxygen.tag'
Filename of the Doxygen tag file every zdocs-built Doxygen document emits, mirroring Sphinx’s own fixed
objects.inv. Doxygen tag file names are not standardised the wayobjects.invis, so this is purely an engine convention — which is exactly why it is named in one place: the value is shared withcmake/doxygen.cmake(which WRITES it, via the appendedGENERATE_TAGFILE) andcmake/registry.cmake(which downloads akind: doxygen-externalpeer’s remote tag file TO it). Those two must be changed together with this one.This is the LOCAL name only. A
doxygen-externalpeer’s remote tag file is named by its ownremote-tagfile:field and may be called anything; the engine renames it to this on download.
- docrefs.NEEDS_TAGFILE = 'needs.tag'
Filename of the Doxygen tag file a
doxygen_tag:Sphinx document publishes beside itsneeds.json(seeneeds_tag()). Named apart fromDOXYGEN_TAGFILEbecause it is not written by Doxygen and describes needs, not symbols. Shared withcmake/registry.cmakeonly through theneeds-tagCLI, which writes it.
- docrefs.grouped_links(this_doc, data, href_fn)[source]
Ordered grouped cross-document links for
this_doc.- Returns
[{"id", "title", "mode", "display", "links": [{"label", "href"}]}]: groups in registry
groups:order; documents in registry order;groups with
display: "no-display"are dropped entirely, as are groups left empty after filtering (no dangling heading);labelis the documenttitle;hrefishref_fn(doc_id, meta);this_docis excluded from its own group’s links, unless that group’smodeis"always_keep"(see_GROUP_MODES).
- Returns
- docrefs.build_root()[source]
Deploy build root, derived from the per-target
OUTPUT_DIR.OUTPUT_DIRis the HTML output directory<root>/deploy/html/<id>(step 22: builder-first layout), so the build root is three levels up — unchanged from the pre-step-22<root>/deploy/<id>/htmlshape, since both nest the same two path segments (a builder name and a doc id, in either order) betweendeployand the root. Useful for locating sibling deploy artifacts (e.g. a Doxygen project’s XML for Breathe).
- docrefs.resolve_version(*, scope=None, project=None, repo_root=None, west=None)[source]
Resolve a document’s displayed version. Single shared implementation.
Used by both the sphinx path (
conf_common.py) and the doxygen path (theversionCLI subcommand). Resolution order:The
VERSIONenv var (CI / reproducible-build escape hatch) wins.scope:git -C <repo_root> describe --tags --match "<scope>/*" --dirty; the leading"<scope>/"is stripped so the version displays clean (proj/0.1-dirty->0.1-dirty). No mandatory “v” prefix on the version component — SOP-DOCCTL’s release tags (<document-id>/<version>, e.g.sop-docctl/1.0, created bydocctl approve) don’t use one; a scope pattern that required “v” never matched those tags and silently fell back to (4) below.project: a west project name — its path is resolved viawest list --format {abspath} <project>(run in the west topdir), thengit -C <path> describe --tags --always --dirty(its own tag, else short sha,-dirtyon a dirty tree).Otherwise / on any error -> graceful dev fallback (
v0.0-dev+g<short-sha of repo_root>).
Never raises. Deterministic for a given HEAD so the value is identical across the two sphinx build stages (xref and html).
- class docrefs.Refs(reference_groups, intersphinx_mapping, needs_external_needs, doxylink, version_scope=None, version_project=None, rel_urls=None, deploy_dirs=None, testmodule=None, symbol_needs=None)[source]
Derived cross-document link structures for the current document.
- testmodule
This document’s own resolved
testmodule:block (step 27), orNonewhen it has none — the registry entry IS the opt-in signalzdocs_conf.configure()uses to load thetest_moduleextension (decision 2), soNonehere must mean “do nothing”, never a dict of empty strings. A dict with keysxml_dir,doxygen_url,api_url,needs_json(each""when the correspondingtestmodule:field is absent, e.g. noapi_reference:), andtag_urls(see tag_urls).
- symbol_needs
This document’s own resolved
symbol_needs:block, orNonewhen it has none (then thesymbol_needsextension is not loaded). A dict withxml_dir(the Doxygen document’s XML) anddoxygen_url(its HTML, relative to this document’s root).
- docrefs.external_doc(doc_id, registry=None)[source]
(title, url)for akind: external/kind: sphinx-external/kind: doxygen-externalregistry document, orNoneifdoc_iddoesn’t exist or isn’t one of those kinds.Used by the
qmsdocrole (sphinx/_extensions/qms_ref.py) to reference e.g. a QMS SOP from content — standard:ref:/:external+prefix:ref:roles can’t resolve these: they have no local label, and (forkind: external) are deliberately excluded fromintersphinx_mapping(noobjects.invto fetch — see documents.yaml’s own field docs). Akind: sphinx-externaldocument IS also inintersphinx_mapping(seeload()), and akind: doxygen-externaldocument IS also indoxylink(seeload()), but:qmsdoc:still resolves any of them the same whole-document way as a plainexternalone.
- docrefs.load(this_doc=None, registry=None)[source]
Build the cross-document link structures for
this_doc.this_docdefaults to the document currently being built (detected fromOUTPUT_DIR); pass it explicitly to override.registrydefaults todoc/documents.yaml.
- docrefs.tag_urls(documents, deploy, rel_urls)[source]
{tag file path: HTML directory URL}for every tag file tagfiles can name.Doxygen marks a reference it resolved through a tag file with
external="<tag file path>", the path exactly asTAGFILESgave it. This map sends such a reference to the document whose tag file it is: for akind: doxygenpeer its HTML directory relative to this document’s root (rel_urls), for akind: doxygen-externalpeer its absolute remote directory, and for a needs tag its Sphinx root.
- docrefs.tagfiles(this_doc, deploy_dir, registry=None)[source]
Doxygen
TAGFILESvalue forthis_doc: an entry for every OTHERkind: doxygendocument in the registry, so any doxygen doc can resolve symbols defined in the others (e.g. testspec → safety-api) — plus an entry for everykind: doxygen-externalpeer, so an internal doxygen document can also resolve symbols documented in an externally-hosted one.Each entry is
<tagfile>=<location>. For an internalkind: doxygenpeer,locationis the target’s HTML dir relative to this doc’s HTML dir (a filesystem hop, since both live under the samedeploy/). For akind: doxygen-externalpeer there is no local HTML dir to be relative to — the tag file is downloaded, but the HTML it links into is hosted remotely — solocationis instead the ABSOLUTE remote base directory url derived fromremote-url:(Doxygen’sTAGFILESnatively accepts an absolute URL as the location half — the standard way to link against an externally-hosted Doxygen site). Unlike the build-time helpers this runs at CMake configure time (noOUTPUT_DIR), sodeploy_diris passed in and entries are emitted unconditionally — Doxygen simply warns (and continues) for a tag file that has not been generated yet.
- docrefs.needs_tag(doc_id, deploy_dir, registry=None)[source]
Write
doc_id’s needs as a Doxygen tag file; return its path.Reads the
needs.jsonthat the document’s own stage-1xrefbuild exported, and writesNEEDS_TAGFILEbeside it. It is the only link from sphinx-needs sources to Doxygen’s\requirementvocabulary, so the requirements stay authored in reStructuredText and are parsed by Sphinx alone. There is no second parser and no generated.dox.Nothing reads the tag before stage 2 (stage-1 Doxygen blanks
TAGFILES), socmake/registry.cmakebuilds it after<doc>-indexand outsidedoc-tags. That ordering is what keeps this cycle-free.The file is rewritten only when its content changes, so an unchanged requirement set does not look new to anything that tracks timestamps.
Grouped cross-document navigation entries for
this_doc.Returns the grouped list
[{id, title, links: [{label, href}]}]for every OTHER document in the registry, grouped by the registry’sgroups:.Consumed by the doxygen cross-doc nav widget (doxygen/cross-doc-nav.js).
hrefis deploy-relative (<path>/index.html) for sphinx/doxygen docs so the widget resolves it against the deploy/ root regardless of where the site is served;external/sphinx-external/doxygen-externaldocs use their absoluteremote-urlverbatim instead — a relative deploy-path href for one of these would resolve, in the reader’s browser, against the CURRENT page’s own URL rather than the deploy root, silently landing back on this site instead of the real remote one. Mirrors the sphinx sidebar’s grouped document list (load()’s own_nav_href), which is derived from the same registry (reference_groups) and has covered all three kinds since steps 20/21.
- docrefs.xml_enabled(registry=None)[source]
Whether the project-scoped
doxygen_xml:opt-in (step 23) is on.A single top-level boolean in the registry — NOT per-document — so this takes no
doc_id: when the key is present and true, EVERYkind: doxygendocument in the registry generates XML; when it is absent (the common case: nothing in this registry ever wrote it) or explicitly false, NONE do, regardless of what any individual document’s ownDoxyfile.insays aboutGENERATE_XML(see the module docstring’sload()example andzdocs-design-deploy-layout.md§3.4).The factory (
add_doxygen_target,cmake/doxygen.cmake) calls this itself, the same way it already shells out totagfiles/navlinks, rather than being handed the answer as a CMake argument byadd_docs_from_registry— that is what makes a document declared via a hand-writtenadd_doxygen_target(... REGISTRY ...)call (bypassingadd_docs_from_registryentirely, asdoc-broken-xref’s fixture does) see the exact same answer as one declared through the registry dispatcher.
- docrefs.manifest(registry=None)[source]
The whole registry, as a plain list of
{id, kind, group, doc_dir, builders, remote_tagfile}dicts, in registry order.Unlike
navlinks/tagfiles(per-document views, keyed onthis_doc), this is a view over the WHOLE registry with no document excluded — the consumer is CMake’sadd_docs_from_registry, which needs every entry (includingkind: externalones, so it can skip them) to generate the per-documentadd_sphinx_target/add_doxygen_targetcalls a consumer used to write by hand.- Each field:
id— the document’s key in the registry.kind— defaults to"sphinx", matchingload()’s own convention (a document with nokind:is a plain Sphinx document).group— as declared;_validate()(via_registry()) already guarantees this is never missing.doc_dir— the RAW value from the registry (Noneif absent). Deliberately not resolved to an absolute path here: only CMake knows wheredocuments.yamllives when it wants to resolve one, and resolving it in Python would bake this script’s notion of “relative to” into the output instead of leaving that to the caller.builders—meta.get("builders", [])for asphinxdocument (the only kind that takes one);[]for every other kind, since neither doxygen nor external documents have a BUILDERS concept.remote_tagfile—meta.get("remote-tagfile")(Nonefor every kind other thandoxygen-external, which is the only one that has this field at all). CMake needs this to build the<id>-tagdownload target’s command; it has no other way to read a single registry field without re-parsing the whole YAML file itself.testmodule_doxygen_source— this document’s owntestmodule:block’sdoxygen_sourcefield (Nonewhen the document carries notestmodule:block, or the block omits that field). Step 28b:add_docs_from_registryuses this to wireadd_dependencies(<doc>-<builder> <doxygen_source>)— the surviving ordering edge fromzdocs-brief-step28b-twister-report.md§2 decision 2/3, fixing the concurrency hazard between a report document’s stage-2 build and its doxygen source’s own stage-2 run both touchingdeploy/xml/<id>/. The value is the RAW registry id (e.g."dox-checks", the fixture that keeps its prefix on purpose), which already IS the doxygen document’s own CMake target name (add_doxygen_targettakes the id verbatim, since step 24b) — no prefix stripping needed here, nor in the “doxygen” dispatch branch below any more; before that step this edge already needed none while that branch did.testmodule_spec— this document’s owntestmodule:block’sspecfield (Nonewhen absent). Step 28b (decision 2, corrected a second time — the edge this field drives was first called redundant, then found to guard a real stage-1 race and reinstated):add_docs_from_registrywiresadd_dependencies(<doc>-index <spec>-index)— both documents’ stage-1 targets are otherwise unordered siblings underdoc-tags, so a report document’s stage-1testreport/twisterinfocan run BEFORE its spec’s stage-1 has exportedneeds.json, silently yielding an incomplete export from whatever DID already run. Unlikedoxygen_source,specnames a SPHINX document (e.g."duties"), so the target name is<spec>-index, not the raw value itself — seeadd_sphinx_target’s own<doc_name>-indexstage-1 target convention. Directional by construction (a document is never its own spec), so this cannot cycle; it is deliberately NOT generalised to every needs-importer/publisher pair, which CAN cycle and is its own, deferred step.symbol_needs_doxygen_source— this document’s ownsymbol_needs:block’sdoxygen_source(Nonewithout the block). Wired liketestmodule_doxygen_source: every stage-2 builder of the document waits for that Doxygen document.doxygen_tag—Truewhen the document declaresdoxygen_tag:.add_docs_from_registrythen adds the<doc>-needstagtarget (seeneeds_tag()).
doccheck
Post-build cross-reference integrity checks, wired by
add_doc_check().
Post-build integrity checks for the document set.
Two failures this catches, both of which shipped silently before it existed:
A cross-reference in the rendered smoke-test page did not resolve. The :external+<inv>: intersphinx role runs at PARSE time, so if the peer’s objects.inv did not exist yet it emits NOTHING AT ALL – not a broken link, not plain text, an empty node. Invisible in the log, invisible on the page unless you know what should be there.
A link into the deploy tree points at a file that is not there.
Both need a built deploy tree, so –deploy is required. There is no sources-only mode: a run that checks nothing must not be able to print OK.
A third check was removed rather than fixed. It scanned document sources for tokens shaped like a document id and reported any that the registry did not declare, to catch content still naming a removed document. Recognising an id by shape means hardcoding one project’s naming convention – the regex was dox-|docctl-|sop- – which is an engine constant standing in for a consumer’s vocabulary, and it cannot be derived from the registry instead: an id that IS in the registry is precisely the case the check must not flag. It also made documentation ABOUT this engine unwritable, since every example id in a tutorial was a finding.
Usage: doccheck.py –registry doc/documents.yaml –deploy DIR Exit: 0 clean, 1 findings, 2 bad invocation.
- doccheck.check_xref_smoketest(deploy: Path, smoke_page: str) list[str][source]
Every bullet in the rendered cross-reference test must have resolved.
The page exists to exercise every link channel from content. A bullet with no <a> in it is a channel that silently produced nothing.
Its location is a CONSUMER setting (xref_smoketest: in the registry), not a constant: it used to be hardcoded to one project’s docctl-swds/html/architecture/xref-test.html, so for every other project this check reported “smoke test not found” – a finding about the checker’s own configuration, on every run, which is the fastest way to teach people to ignore it.
docctl
The author/review/approve controlled-document workflow. Declared with a
bare .. py:module:: rather than automodule: the module’s own
docstring opens a bulleted list directly after a paragraph with no
separating blank line, which reST reads as “Unexpected indentation” — a
source defect automodule cannot route around via a directive option
(unlike the single excluded members above, the broken text here IS the
module docstring itself). None of its subcommand functions
(cmd_list/cmd_author/cmd_review/cmd_approve/
cmd_bump_version) carry their own docstring, so there is no member-level
autodoc to fall back to either; see the companion manual’s
Command-line tools page for the
task-facing description of every subcommand, and scripts/docctl.py
itself for the implementation. See the final report for this finding.