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 way objects.inv is, so this is purely an engine convention — which is exactly why it is named in one place: the value is shared with cmake/doxygen.cmake (which WRITES it, via the appended GENERATE_TAGFILE) and cmake/registry.cmake (which downloads a kind: doxygen-external peer’s remote tag file TO it). Those two must be changed together with this one.

This is the LOCAL name only. A doxygen-external peer’s remote tag file is named by its own remote-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 its needs.json (see needs_tag()). Named apart from DOXYGEN_TAGFILE because it is not written by Doxygen and describes needs, not symbols. Shared with cmake/registry.cmake only through the needs-tag CLI, which writes it.

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);

  • label is the document title; href is href_fn(doc_id, meta);

  • this_doc is excluded from its own group’s links, unless that group’s mode is "always_keep" (see _GROUP_MODES).

docrefs.build_root()[source]

Deploy build root, derived from the per-target OUTPUT_DIR.

OUTPUT_DIR is 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>/html shape, since both nest the same two path segments (a builder name and a doc id, in either order) between deploy and 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 (the version CLI subcommand). Resolution order:

  1. The VERSION env var (CI / reproducible-build escape hatch) wins.

  2. 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 by docctl approve) don’t use one; a scope pattern that required “v” never matched those tags and silently fell back to (4) below.

  3. project: a west project name — its path is resolved via west list --format {abspath} <project> (run in the west topdir), then git -C <path> describe --tags --always --dirty (its own tag, else short sha, -dirty on a dirty tree).

  4. 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), or None when it has none — the registry entry IS the opt-in signal zdocs_conf.configure() uses to load the test_module extension (decision 2), so None here must mean “do nothing”, never a dict of empty strings. A dict with keys xml_dir, doxygen_url, api_url, needs_json (each "" when the corresponding testmodule: field is absent, e.g. no api_reference:), and tag_urls (see tag_urls).

symbol_needs

This document’s own resolved symbol_needs: block, or None when it has none (then the symbol_needs extension is not loaded). A dict with xml_dir (the Doxygen document’s XML) and doxygen_url (its HTML, relative to this document’s root).

docrefs.external_doc(doc_id, registry=None)[source]

(title, url) for a kind: external/kind: sphinx-external/ kind: doxygen-external registry document, or None if doc_id doesn’t exist or isn’t one of those kinds.

Used by the qmsdoc role (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 (for kind: external) are deliberately excluded from intersphinx_mapping (no objects.inv to fetch — see documents.yaml’s own field docs). A kind: sphinx-external document IS also in intersphinx_mapping (see load()), and a kind: doxygen-external document IS also in doxylink (see load()), but :qmsdoc: still resolves any of them the same whole-document way as a plain external one.

docrefs.load(this_doc=None, registry=None)[source]

Build the cross-document link structures for this_doc.

this_doc defaults to the document currently being built (detected from OUTPUT_DIR); pass it explicitly to override. registry defaults to doc/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 as TAGFILES gave it. This map sends such a reference to the document whose tag file it is: for a kind: doxygen peer its HTML directory relative to this document’s root (rel_urls), for a kind: doxygen-external peer its absolute remote directory, and for a needs tag its Sphinx root.

docrefs.tagfiles(this_doc, deploy_dir, registry=None)[source]

Doxygen TAGFILES value for this_doc: an entry for every OTHER kind: doxygen document in the registry, so any doxygen doc can resolve symbols defined in the others (e.g. testspec → safety-api) — plus an entry for every kind: doxygen-external peer, so an internal doxygen document can also resolve symbols documented in an externally-hosted one.

Each entry is <tagfile>=<location>. For an internal kind: doxygen peer, location is the target’s HTML dir relative to this doc’s HTML dir (a filesystem hop, since both live under the same deploy/). For a kind: doxygen-external peer there is no local HTML dir to be relative to — the tag file is downloaded, but the HTML it links into is hosted remotely — so location is instead the ABSOLUTE remote base directory url derived from remote-url: (Doxygen’s TAGFILES natively 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 (no OUTPUT_DIR), so deploy_dir is 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.json that the document’s own stage-1 xref build exported, and writes NEEDS_TAGFILE beside it. It is the only link from sphinx-needs sources to Doxygen’s \requirement vocabulary, 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), so cmake/registry.cmake builds it after <doc>-index and outside doc-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’s groups:.

Consumed by the doxygen cross-doc nav widget (doxygen/cross-doc-nav.js). href is 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-external docs use their absolute remote-url verbatim 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, EVERY kind: doxygen document 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 own Doxyfile.in says about GENERATE_XML (see the module docstring’s load() example and zdocs-design-deploy-layout.md §3.4).

The factory (add_doxygen_target, cmake/doxygen.cmake) calls this itself, the same way it already shells out to tagfiles/navlinks, rather than being handed the answer as a CMake argument by add_docs_from_registry — that is what makes a document declared via a hand-written add_doxygen_target(... REGISTRY ...) call (bypassing add_docs_from_registry entirely, as doc-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 on this_doc), this is a view over the WHOLE registry with no document excluded — the consumer is CMake’s add_docs_from_registry, which needs every entry (including kind: external ones, so it can skip them) to generate the per-document add_sphinx_target/add_doxygen_target calls a consumer used to write by hand.

Each field:
  • id — the document’s key in the registry.

  • kind — defaults to "sphinx", matching load()’s own convention (a document with no kind: 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 (None if absent). Deliberately not resolved to an absolute path here: only CMake knows where documents.yaml lives 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 a sphinx document (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") (None for every kind other than doxygen-external, which is the only one that has this field at all). CMake needs this to build the <id>-tag download 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 own testmodule: block’s doxygen_source field (None when the document carries no testmodule: block, or the block omits that field). Step 28b: add_docs_from_registry uses this to wire add_dependencies(<doc>-<builder> <doxygen_source>) — the surviving ordering edge from zdocs-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 touching deploy/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_target takes 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 own testmodule: block’s spec field (None when 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_registry wires add_dependencies(<doc>-index <spec>-index) — both documents’ stage-1 targets are otherwise unordered siblings under doc-tags, so a report document’s stage-1 testreport/twisterinfo can run BEFORE its spec’s stage-1 has exported needs.json, silently yielding an incomplete export from whatever DID already run. Unlike doxygen_source, spec names a SPHINX document (e.g. "duties"), so the target name is <spec>-index, not the raw value itself — see add_sphinx_target’s own <doc_name>-index stage-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 own symbol_needs: block’s doxygen_source (None without the block). Wired like testmodule_doxygen_source: every stage-2 builder of the document waits for that Doxygen document.

  • doxygen_tag — True when the document declares doxygen_tag:. add_docs_from_registry then adds the <doc>-needstag target (see needs_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:

  1. 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.

  2. 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]
  1. 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.

  1. Deploy-internal links that point at a missing file.

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.