From 8a1bea0cdd583c1218b19ca15d6d99cd108fc473 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sun, 27 Sep 2026 08:40:48 +0200 Subject: [PATCH 1/4] feat: doxygen: read native \verifies requirement links Doxygen 1.16 writes \verifies as a child of the memberdef, one per UID with the UID only in its refid. parse_memberdef read requirement links from @reqref xrefsects alone, so test cases annotated with the native command reached the needs with no verifies links. Read memberdef/verifies/requirement beside the xrefsect path, keeping both while sources migrate, and list a UID named by both once. The refid does not prove the requirement exists: Doxygen synthesizes it from the UID whether or not any \requirement defines it, so validation stays with Doxygen's "unknown requirement" warning, as the new comment says. Three new tests; against the unchanged code 2 fail, the third pins that non-requirement children are ignored. Suite 164 -> 167. In the safety docset 54 of 56 test cases now carry 77 verifies links, none dangling. The acceptance suite (zdocs-tests) was not available and has not been run. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .../_extensions/_tests/test_doxygen_parser.py | 40 ++++++++++++++++++- sphinx/_extensions/doxygen_parser.py | 14 +++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index 1e3548b..ea91f17 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -192,18 +192,26 @@ def test_see_to_rst_ref_unresolved(): # parse_memberdef # --------------------------------------------------------------------------- -def _make_memberdef(extra_xrefsects="", inbody=""): +def _make_memberdef(extra_xrefsects="", inbody="", member_extra=""): return ET.fromstring( f"" f"test_queue_put" f"Test queue put." f"{extra_xrefsects}" f"{inbody}" + f"{member_extra}" f"" f"" ) +def _verifies(*uids): + """Doxygen 1.16's native `\\verifies` output: a child of the + memberdef, one per UID, the UID only in its refid.""" + reqs = "".join(f"" for uid in uids) + return f"{reqs}" + + def test_parse_memberdef_extracts_testid(): xref = ( "" @@ -228,6 +236,36 @@ def test_parse_memberdef_extracts_reqrefs(): assert "zep-srs-20-1" in info["req_ids"] +def test_parse_memberdef_extracts_native_verifies(): + md = _make_memberdef(member_extra=_verifies("ZEP-SRS-20-6", "ZEP-SRS-20-7")) + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["req_ids"] == ["ZEP-SRS-20-6", "ZEP-SRS-20-7"] + + +def test_parse_memberdef_native_verifies_and_reqrefs_coexist(): + # Both paths are live during the migration (D2); a UID named by both is + # listed once. + xref = ( + "" + "Requirement Refs" + "ZEP-SRS-20-1" + "" + ) + md = _make_memberdef( + extra_xrefsects=xref, member_extra=_verifies("ZEP-SRS-20-1", "ZEP-SRS-20-2") + ) + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["req_ids"] == ["ZEP-SRS-20-1", "ZEP-SRS-20-2"] + + +def test_parse_memberdef_ignores_verifies_of_other_kinds(): + # Only children carry a UID; anything else Doxygen may put + # there is not a requirement link. + md = _make_memberdef(member_extra="x") + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["req_ids"] == [] + + def test_parse_memberdef_active_status(): xref = ( "" diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index 2166287..8b85b38 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -339,6 +339,20 @@ def parse_memberdef( if see_sect is not None: see_rst = see_to_rst(see_sect, api_html_dir) + # Doxygen's native `\verifies` (1.16+): a child of the memberdef + # itself, not of the description, with the UID only in each requirement's + # refid. Read beside the `@reqref` xrefsects above; both are live while + # sources migrate. + # + # The refid is NOT proof the requirement exists: Doxygen synthesizes it from + # the UID string whether or not any `\requirement` defines it, so a typo is + # byte-identical here to a real link. Only Doxygen's warning ("Reference to + # unknown requirement") tells them apart. + for req in memberdef.findall("verifies/requirement"): + uid = req.get("refid", "").removeprefix("requirement_") + if uid and uid not in req_ids: + req_ids.append(uid) + ibd = memberdef.find("inbodydescription") body_sections: list[list[str]] = [] if ibd is not None: From 0a98275cf24f14ecf36cf0f16f6f50bb1b742d8a Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sun, 27 Sep 2026 08:57:54 +0200 Subject: [PATCH 2/4] feat: doxygen: fail stage 2 on unknown requirement references Doxygen synthesizes from the UID whether or not any \requirement defines it, so the XML cannot tell a real \verifies or \satisfies link from a typo. Its "Reference to unknown requirement" warning is the only signal, and nothing acted on it: a build with a truncated UID passed. Stage 2 now writes its warnings to /.warnings.log, and a new check_doxygen_warnings.cmake runs after it in the same target. It echoes the log, so the console shows what it did before, and fails the target on any line matching ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS, naming each line. The default pattern is the unknown-requirement warning; an empty list gates nothing. Stage 1 is never gated: it clears TAGFILES, so its cross-document warnings are false, and its overlay resets WARN_LOGFILE. Five new tests run the script through cmake -P. Suite 167 -> 172. In the safety docset a planted @verifies ZEP-SRS-20-99 fails the build at its source line and passes once removed. The acceptance suite (zdocs-tests) was not available and has not been run. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- cmake/check_doxygen_warnings.cmake | 72 +++++++++++++++++++ cmake/doxygen.cmake | 31 +++++++- cmake/zdocs.cmake | 5 ++ doc/api/cmake/modules.rst | 2 + doc/manual/reference/consumer-contract.rst | 10 +++ .../_tests/test_doxygen_warn_gate.py | 58 +++++++++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 cmake/check_doxygen_warnings.cmake create mode 100644 sphinx/_extensions/_tests/test_doxygen_warn_gate.py diff --git a/cmake/check_doxygen_warnings.cmake b/cmake/check_doxygen_warnings.cmake new file mode 100644 index 0000000..5a091b1 --- /dev/null +++ b/cmake/check_doxygen_warnings.cmake @@ -0,0 +1,72 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 +# +# Build-time gate over a Doxygen warning log. +# +# Invoked as `cmake -P check_doxygen_warnings.cmake` right after stage 2's +# Doxygen run, from the same custom target in doxygen.cmake. +# +# Required -D arguments: +# DOC_ID registry id of the document, for the messages +# LOGFILE the WARN_LOGFILE stage 2 wrote +# PATTERNS ;-separated regular expressions; a log line matching any of them +# fails the build. Empty: the log is echoed and nothing fails. + +#[==[.rst: +check_doxygen_warnings.cmake +============================ + +Internal ``cmake -P`` gate run after :cmake:command:`add_doxygen_target`'s +stage-2 Doxygen build. Stage 2 writes its warnings to a log file instead of the +console; this script echoes that log, so the console shows what it always did, +then fails the target if any line matches one of +``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS``. + +Stage 2 only, deliberately. Stage 1 runs with ``TAGFILES`` cleared, so every +reference into a peer document — including every ``\verifies`` and +``\satisfies`` against another project's ``\requirement`` — warns there +falsely; stage 1 is therefore silenced and never gated. + +The gate exists because the Doxygen XML cannot tell a real requirement link +from a typo: Doxygen synthesizes ```` +from the UID string whether or not any ``\requirement`` defines it. Its warning +is the only signal. +#]==] + +foreach(var DOC_ID LOGFILE) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "check_doxygen_warnings.cmake: missing required -D${var}") + endif() +endforeach() + +# Doxygen writes the log even when there is nothing to say; a missing one means +# it never ran this configuration, and doxygen's own failure is reported by +# whichever command ran it. +if(NOT EXISTS ${LOGFILE}) + return() +endif() + +file(READ ${LOGFILE} log) +if(NOT log STREQUAL "") + # To stderr, like doxygen itself; NOTICE adds no prefix and no call stack. + string(REGEX REPLACE "\n$" "" log "${log}") + message(NOTICE "${log}") +endif() + +if("${PATTERNS}" STREQUAL "") + return() +endif() + +string(REPLACE ";" "|" regex "${PATTERNS}") +file(STRINGS ${LOGFILE} hits REGEX "${regex}") +if(hits) + list(LENGTH hits count) + list(JOIN hits "\n " listing) + message( + FATAL_ERROR + "${DOC_ID}: ${count} Doxygen warning(s) match ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS:\n" + " ${listing}\n" + "Full log: ${LOGFILE}" + ) +endif() diff --git a/cmake/doxygen.cmake b/cmake/doxygen.cmake index 4c134b5..ff9f6f2 100644 --- a/cmake/doxygen.cmake +++ b/cmake/doxygen.cmake @@ -33,6 +33,16 @@ document's registry id, verbatim. # Doxygen. Requiring it here makes the failure say what it is, at configure time. find_package(Doxygen REQUIRED) +# Stage-2 warnings that fail the build (see check_doxygen_warnings.cmake). The +# default is the one warning that is always a defect and that nothing else +# catches: a `\verifies` / `\satisfies` naming a requirement no `\requirement` +# defines. Doxygen synthesizes the XML link from the UID either way, so a typo +# is otherwise indistinguishable from a real link. A consumer may add patterns, +# or set the list empty to gate nothing. +if(NOT DEFINED ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS) + set(ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS "Reference to unknown requirement") +endif() + #------------------------------------------------------------------------------- # Doxygen (standalone) # @@ -203,6 +213,15 @@ function(add_doxygen_target name) ) endif() + # Stage 2's warnings go to a log that check_doxygen_warnings.cmake echoes + # and gates; stage 1 resets it below. + set(DOXY_WARN_LOG ${CMAKE_CURRENT_BINARY_DIR}/${DOXY_DOC}.warnings.log) + file( + APPEND ${DOXYFILE_OUT} + "\n# --- WARN_LOGFILE (appended by zdocs/cmake/doxygen.cmake) ---\n" + "WARN_LOGFILE = ${DOXY_WARN_LOG}\n" + ) + # -- Inter-doxygen TAGFILES (registry-driven): let this doc resolve symbols # documented in the other doxygen docs (e.g. testspec -> api). Appended to the # GENERATED doxyfile rather than substituted into each Doxyfile.in. @@ -337,6 +356,8 @@ function(add_doxygen_target name) "WARN_NO_PARAMDOC = NO\n" "WARN_IF_UNDOC_ENUM_VAL = NO\n" "WARN_AS_ERROR = NO\n" + # Not stage 2's log: this run must neither overwrite nor feed its gate. + "WARN_LOGFILE =\n" ) # Doxygen is invoked through run_doxygen.cmake (a `cmake -P` wrapper) so a @@ -392,7 +413,15 @@ function(add_doxygen_target name) add_dependencies(doc-tags ${DOXY_DOC}-tag) add_dependencies(doc-index ${DOXY_DOC}-tag) - add_doc_target(${DOXY_DOC} COMMAND ${DOX_STAGE2_CMD} COMMENT "Running Doxygen for ${DOXY_DOC}...") + add_doc_target( + ${DOXY_DOC} + COMMAND ${DOX_STAGE2_CMD} + COMMAND + ${CMAKE_COMMAND} -DDOC_ID=${DOXY_DOC} -DLOGFILE=${DOXY_WARN_LOG} + "-DPATTERNS=${ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS}" + -P ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/check_doxygen_warnings.cmake + COMMENT "Running Doxygen for ${DOXY_DOC}..." + ) # Stage 2 waits for all stage-1 tags so TAGFILES cross references resolve. add_dependencies(${DOXY_DOC} doc-index) diff --git a/cmake/zdocs.cmake b/cmake/zdocs.cmake index e8c71af..a60cd39 100644 --- a/cmake/zdocs.cmake +++ b/cmake/zdocs.cmake @@ -65,6 +65,11 @@ list(APPEND CMAKE_MODULE_PATH ${ZDOCS_CMAKE_DIR}) # ZDOCS_PROJECT_LOGO (optional) logo for Doxygen pages. # ZDOCS_DOXYGEN_EXTRA_CSS (optional) brand stylesheets, appended after the # theme so they win. +# ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS +# (optional) regexes; a stage-2 Doxygen warning +# matching one fails that document's build. Defaults +# to Doxygen's "Reference to unknown requirement"; +# set it empty to gate nothing. # ZDOCS_DOC_BASE_URL (optional) base URL the deploy tree is served under, # used for absolute cross-document need links. # ZDOCS_SPHINX_EXTRA_ENV (optional) extra VAR=value entries passed to every diff --git a/doc/api/cmake/modules.rst b/doc/api/cmake/modules.rst index a84c145..bc5c501 100644 --- a/doc/api/cmake/modules.rst +++ b/doc/api/cmake/modules.rst @@ -19,3 +19,5 @@ surface — it is the bracket comments themselves, rendered. .. cmake-module:: /cmake/download_external_tag.cmake .. cmake-module:: /cmake/run_doxygen.cmake + +.. cmake-module:: /cmake/check_doxygen_warnings.cmake diff --git a/doc/manual/reference/consumer-contract.rst b/doc/manual/reference/consumer-contract.rst index 7a755e7..8b760bd 100644 --- a/doc/manual/reference/consumer-contract.rst +++ b/doc/manual/reference/consumer-contract.rst @@ -59,6 +59,16 @@ layout, not toggled from outside it. A list of stylesheet paths, appended to Doxygen's ``HTML_EXTRA_STYLESHEET`` after the engine's own theme — so your rules win. +``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS`` + A list of regular expressions. After each Doxygen document's stage-2 build, + a warning line matching any of them fails that document's target; the full + warning log is still printed either way. Defaults to + ``Reference to unknown requirement`` — a ``\verifies`` or ``\satisfies`` + naming a UID that no ``\requirement`` defines, which the XML cannot reveal + (Doxygen synthesizes the link from the UID regardless). Set it to an empty + string to gate nothing. Stage 1 is never gated: it runs with ``TAGFILES`` + cleared, so its cross-document warnings are false. + ``ZDOCS_SPHINX_EXTRA_ENV`` A list of ``VAR=value`` strings, spliced verbatim into every ``sphinx-build`` invocation's environment, for a ``conf.py`` that needs diff --git a/sphinx/_extensions/_tests/test_doxygen_warn_gate.py b/sphinx/_extensions/_tests/test_doxygen_warn_gate.py new file mode 100644 index 0000000..f1cefac --- /dev/null +++ b/sphinx/_extensions/_tests/test_doxygen_warn_gate.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""The stage-2 Doxygen warning gate fails only on the configured patterns.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +GATE = Path(__file__).resolve().parents[3] / "cmake" / "check_doxygen_warnings.cmake" +DEFAULT = "Reference to unknown requirement" + +TYPO = "src/main.c:467: warning: Reference to unknown requirement 'ZEP-SRS-24-' found" +UNDOC = "src/main.c:12: warning: Member foo (function) of file main.c is not documented." + +pytestmark = pytest.mark.skipif(shutil.which("cmake") is None, reason="needs cmake") + + +def run(tmp_path, lines, patterns=DEFAULT, write_log=True): + log = tmp_path / "doc.warnings.log" + if write_log: + log.write_text("".join(line + "\n" for line in lines)) + return subprocess.run( + ["cmake", "-DDOC_ID=doc", f"-DLOGFILE={log}", f"-DPATTERNS={patterns}", "-P", GATE], + capture_output=True, + text=True, + ) + + +def test_unknown_requirement_fails_and_names_the_line(tmp_path): + result = run(tmp_path, [UNDOC, TYPO]) + assert result.returncode != 0 + assert "1 Doxygen warning(s) match" in result.stderr + assert "'ZEP-SRS-24-'" in result.stderr + + +def test_other_warnings_pass_and_are_still_printed(tmp_path): + result = run(tmp_path, [UNDOC]) + assert result.returncode == 0 + assert UNDOC in result.stderr + + +def test_empty_patterns_gate_nothing(tmp_path): + result = run(tmp_path, [TYPO], patterns="") + assert result.returncode == 0 + assert TYPO in result.stderr + + +def test_any_of_several_patterns_fails(tmp_path): + result = run(tmp_path, [UNDOC], patterns=f"{DEFAULT};is not documented") + assert result.returncode != 0 + + +def test_missing_log_passes(tmp_path): + assert run(tmp_path, [], write_log=False).returncode == 0 From 3e4c1c4fbfdc8b568e40b21d3dd36946dbd5255c Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sun, 27 Sep 2026 17:34:12 +0200 Subject: [PATCH 3/4] fix: doxygen: link symbol references in test-case prose A symbol reference in a test case's or procedure's prose - brief, details, test steps, Arrange/Act/Assert sections, parameters - became a :c:func: role. Nothing in the test documents defines C objects, so it rendered as code without a link, and without a warning, while the same symbol on the see-also line linked into the Doxygen HTML through its refid. One helper, ref_to_rst, now turns a into a link for both: a symbol resolved through a tag file (external=) into the API document's Doxygen, one documented in the parsed project itself, such as a shared test procedure, into the test specification's. The prose helpers take it as an optional links= argument; without it their output is unchanged. A procedure's brief is its need title, which is not parsed, so it stays unlinked. para_text also joined its pieces with a space, which put one before the punctuation after a reference ("k_fifo_put() ."); it now joins them as written and only collapses whitespace. Six new tests; against the unchanged code five fail, the sixth pins the unlinked output. In the safety test specification every k_fifo_* and k_queue_* reference now links; the 37 references left unlinked name undocumented local helpers, which have no Doxygen page. The acceptance suite (zdocs-tests) was not available and has not been run. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .../_extensions/_tests/test_doxygen_parser.py | 57 +++++++++ .../_extensions/_tests/test_rst_builders.py | 24 ++++ sphinx/_extensions/doxygen_parser.py | 115 +++++++++++++----- sphinx/_extensions/rst_builders.py | 9 +- 4 files changed, 170 insertions(+), 35 deletions(-) diff --git a/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index ea91f17..65f75cd 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -446,3 +446,60 @@ def test_extract_params_basic(): assert len(params) == 1 assert params[0][0] == "queue" assert "queue pointer" in params[0][1] + + +# --------------------------------------------------------------------------- +# symbol links in prose (brief, details, test steps, Arrange/Act/Assert) +# +# The see-also line linked a symbol into the Doxygen HTML; the same symbol in +# the prose became a :c:func: role, which nothing in the test documents can +# resolve, so it rendered as code with no link and no warning. +# --------------------------------------------------------------------------- + +API = "../api" +SPEC = "../testspec" +EXT_REF = ( + "k_fifo_get()" +) +LOCAL_REF = "get_scratch_packet()" + + +def test_para_text_links_external_symbol_into_api_doxygen(): + para = ET.fromstring(f"Call {EXT_REF} now.") + text = dp.para_text(para, links=dp.RefLinks(api=API, local=SPEC)) + assert "`k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__" in text + assert ":c:func:" not in text + + +def test_para_text_links_local_symbol_into_testspec_doxygen(): + para = ET.fromstring(f"Call {LOCAL_REF}.") + text = dp.para_text(para, links=dp.RefLinks(api=API, local=SPEC)) + assert "`get_scratch_packet() <../testspec/group__procs.html#ga3f93>`__" in text + + +def test_para_text_without_links_is_unchanged(): + para = ET.fromstring(f"Call {EXT_REF}.") + assert dp.para_text(para) == "Call :c:func:`k_fifo_get`." + + +def test_parse_memberdef_links_symbols_in_brief_details_and_body(): + md = ET.fromstring( + "test_x" + f"Verify {EXT_REF}." + f"Details {EXT_REF}." + "Act" + f"Call {EXT_REF}." + "" + "" + ) + info = dp.parse_memberdef(md, "group__s", SPEC, API) + link = "<../api/group__fifo__apis.html#ga1e2c>`__" + assert link in info["brief"] + assert any(link in line for line in info["detail_lines"]) + assert any(link in line for sect in info["body_sections"] for line in sect) + + +def test_see_to_rst_links_local_symbol_when_testspec_dir_given(): + see = ET.fromstring(f"{LOCAL_REF}") + assert "../testspec/group__procs.html#ga3f93" in dp.see_to_rst(see, API, SPEC) diff --git a/sphinx/_extensions/_tests/test_rst_builders.py b/sphinx/_extensions/_tests/test_rst_builders.py index 570099f..3460820 100644 --- a/sphinx/_extensions/_tests/test_rst_builders.py +++ b/sphinx/_extensions/_tests/test_rst_builders.py @@ -194,3 +194,27 @@ def test_build_scenario_table_renders_list_table(tmp_path): def test_build_scenario_table_missing_yaml(): lines = rb.build_scenario_table(FIXTURES / "nonexistent.yaml") assert lines == [] + + +def test_build_procedure_need_rst_links_prose_but_keeps_title_plain(): + # A need's title is not parsed as RST, so link markup there would render + # verbatim; the details are parsed and link like the see-also line does. + import xml.etree.ElementTree as ET + ref = ( + "k_fifo_get()" + ) + memberdef = ET.fromstring( + "" + "drain" + f"Drain via {ref}." + f"Calls {ref} until empty." + "" + "" + ) + rst = rb.build_procedure_need_rst( + memberdef, "group__queue__procedures", "queue_procedures", "../testspec", "../api" + ) + title = rst.splitlines()[0] + assert "<" not in title and "k_fifo_get" in title + assert "`k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__ until empty" in rst diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index 8b85b38..0e85725 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -5,10 +5,12 @@ """Doxygen XML parsing — no Sphinx dependency.""" import xml.etree.ElementTree as ET from pathlib import Path -from typing import TypedDict +from typing import NamedTuple, TypedDict __all__ = [ "MemberInfo", + "RefLinks", + "ref_to_rst", "elem_text", "para_text", "list_to_rst_lines", @@ -35,6 +37,41 @@ class MemberInfo(TypedDict): body_sections: list[list[str]] +class RefLinks(NamedTuple): + """Where a in Doxygen prose points, as HTML directory URLs. + + ``api`` for a symbol Doxygen resolved through a tag file (``external=``) — + the API document the test specification references; ``local`` for one + documented in the parsed project itself, such as a shared test procedure. + An empty string leaves that kind unlinked. + """ + + api: str = "" + local: str = "" + + +def ref_to_rst(ref: ET.Element, links: RefLinks | None) -> str | None: + """A as an RST hyperlink into the Doxygen HTML, or None if it has none. + + Doxygen's refid is ``_1`` for a member and the bare + compound for a page or group. The caller decides what an unlinked ref + becomes. + """ + name = (ref.text or "").strip() + refid = ref.get("refid", "") + if not (links and name and refid): + return None + base = links.api if ref.get("external") else links.local + if not base: + return None + if "_1" in refid: + idx = refid.rfind("_1") + url = f"{base}/{refid[:idx]}.html#{refid[idx + 2:]}" + else: + url = f"{base}/{refid}.html" + return f"`{name} <{url}>`__" + + def elem_text(elem: ET.Element | None) -> str: """Walk the element tree collecting all text nodes (.text and .tail), join them, then normalise any runs of whitespace down to single spaces.""" @@ -51,8 +88,15 @@ def elem_text(elem: ET.Element | None) -> str: return " ".join(" ".join(buf).split()) -def para_text(para: ET.Element | None) -> str: - """Extract inline text from a , rendering code/ref as plain text.""" +def para_text(para: ET.Element | None, links: RefLinks | None = None) -> str: + """Extract inline text from a , rendering code/ref as plain text. + + With ``links``, a symbol reference becomes a hyperlink into the Doxygen HTML + (see `ref_to_rst`) — the same target the see-also line links to. Without, + a member reference becomes a ``:c:func:`` role, which resolves only where a + C domain defines the symbol; in the test documents nothing does, so it + renders as unlinked code, silently. The need title must stay plain text + (it is not parsed), so a caller building one passes no ``links``.""" if para is None: return "" parts = [] @@ -63,7 +107,9 @@ def para_text(para: ET.Element | None) -> str: pass # skip structural children elif child.tag == "computeroutput": ref = child.find("ref[@kindref='member']") - if ref is not None: + if ref is not None and (link := ref_to_rst(ref, links)): + parts.append(link) + elif ref is not None: parts.append(f":c:func:`{(ref.text or '').rstrip('()').strip()}`") else: parts.append(f"``{elem_text(child)}``") @@ -72,7 +118,9 @@ def para_text(para: ET.Element | None) -> str: elif child.tag == "ref": kindref = child.get("kindref", "") t = (child.text or "").strip() - if kindref == "member" and t: + if link := ref_to_rst(child, links): + parts.append(link) + elif kindref == "member" and t: parts.append(f":c:func:`{t.rstrip('()').strip()}`") else: parts.append(elem_text(child)) @@ -80,10 +128,14 @@ def para_text(para: ET.Element | None) -> str: parts.append(elem_text(child)) if child.tail: parts.append(child.tail) - return " ".join(" ".join(parts).split()) + # Joined as written, then whitespace runs collapsed. Joining with a space + # put one before the punctuation that follows a reference ("k_fifo_put() ."). + return " ".join("".join(parts).split()) -def list_to_rst_lines(listelem: ET.Element, marker: str) -> list[str]: +def list_to_rst_lines( + listelem: ET.Element, marker: str, links: RefLinks | None = None +) -> list[str]: """Convert or children to RST list lines. Each item's paragraphs go through `para_rst_lines`, so a list nested inside a @@ -99,7 +151,7 @@ def list_to_rst_lines(listelem: ET.Element, marker: str) -> list[str]: for item in listelem.findall("listitem"): item_lines: list[str] = [] for para in item.findall("para"): - block = para_rst_lines(para) + block = para_rst_lines(para, links) if block: if item_lines: item_lines.append("") @@ -112,7 +164,7 @@ def list_to_rst_lines(listelem: ET.Element, marker: str) -> list[str]: return lines -def para_rst_lines(para: ET.Element | None) -> list[str]: +def para_rst_lines(para: ET.Element | None, links: RefLinks | None = None) -> list[str]: """One as RST lines: its own prose first, then any lists it contains. `para_text` deliberately skips `orderedlist`/`itemizedlist` (and @@ -130,14 +182,14 @@ def para_rst_lines(para: ET.Element | None) -> list[str]: if para is None: return [] lines: list[str] = [] - text = para_text(para).strip() + text = para_text(para, links).strip() if text: lines.append(text) for child in para: if child.tag == "orderedlist": - sub = list_to_rst_lines(child, "#.") + sub = list_to_rst_lines(child, "#.", links) elif child.tag == "itemizedlist": - sub = list_to_rst_lines(child, "-") + sub = list_to_rst_lines(child, "-", links) else: continue if not sub: @@ -151,7 +203,7 @@ def para_rst_lines(para: ET.Element | None) -> list[str]: return lines -def section_to_rst(simplesect: ET.Element) -> list[str]: +def section_to_rst(simplesect: ET.Element, links: RefLinks | None = None) -> list[str]: """Render a (Arrange/Act/Assert) into RST lines. Emits a .. rubric:: for the title, then renders each child as an ordered list, unordered list, or plain prose paragraph.""" @@ -170,7 +222,7 @@ def section_to_rst(simplesect: ET.Element) -> list[str]: for child in simplesect: if child.tag != "para": continue - block = para_rst_lines(child) + block = para_rst_lines(child, links) if block: lines.extend(block) lines.append("") @@ -180,25 +232,21 @@ def section_to_rst(simplesect: ET.Element) -> list[str]: return lines -def see_to_rst(simplesect_see: ET.Element, api_html_dir: str) -> str: +def see_to_rst(simplesect_see: ET.Element, api_html_dir: str, testspec_html_dir: str = "") -> str: """Render a into a 'See also:' RST line. - Each becomes a hyperlink (safety-API refs), a :c:func: role - (member refs), or a plain code span, depending on its attributes.""" + Each becomes a hyperlink (see `ref_to_rst`), a :c:func: role + (unlinked member refs), or a plain code span, depending on its attributes.""" + links = RefLinks(api=api_html_dir, local=testspec_html_dir) refs: list[str] = [] for ref in simplesect_see.findall("para/ref"): name = (ref.text or "").strip() if not name: continue refid = ref.get("refid", "") - external = ref.get("external", "") kindref = ref.get("kindref", "") if refid and "_1" in refid: - idx = refid.rfind("_1") - compound = refid[:idx] - anchor = refid[idx + 2:] - if external: - url = f"{api_html_dir}/{compound}.html#{anchor}" - refs.append(f"`{name} <{url}>`__") + if link := ref_to_rst(ref, links): + refs.append(link) elif kindref == "member": func_name = name.rstrip("()").strip() refs.append(f":c:func:`{func_name}`") @@ -211,7 +259,9 @@ def see_to_rst(simplesect_see: ET.Element, api_html_dir: str) -> str: return "" -def extract_params(detaileddesc: ET.Element) -> list[tuple[str, str]]: +def extract_params( + detaileddesc: ET.Element, links: RefLinks | None = None +) -> list[tuple[str, str]]: """Walk all elements in the detailed description, collect each parameter's name(s) and prose description, and return them as (name, description) pairs. Multiple names per item are joined with ', '.""" @@ -224,7 +274,7 @@ def extract_params(detaileddesc: ET.Element) -> list[tuple[str, str]]: ] name = ", ".join(n for n in names if n) desc = " ".join( - para_text(p) + para_text(p, links) for p in item.findall(".//parameterdescription/para") ).strip() if name: @@ -233,7 +283,7 @@ def extract_params(detaileddesc: ET.Element) -> list[tuple[str, str]]: -def detail_rst_lines(dd: ET.Element | None) -> list[str]: +def detail_rst_lines(dd: ET.Element | None, links: RefLinks | None = None) -> list[str]: """A 's own prose as RST LINES — paragraphs and lists. Shared by member-level (`@details` on a ZTEST/function) and compound-level @@ -262,7 +312,7 @@ def detail_rst_lines(dd: ET.Element | None) -> list[str]: return [] lines: list[str] = [] for para in dd.findall("para"): - block = para_rst_lines(para) + block = para_rst_lines(para, links) if block: if lines: lines.append("") @@ -294,7 +344,8 @@ def parse_memberdef( anchor = member_id[len(prefix):] if member_id.startswith(prefix) else member_id doxygen_url = f"{testspec_html_dir}/{compound_id}.html#{anchor}" - brief = para_text(memberdef.find("briefdescription/para")) + links = RefLinks(api=api_html_dir, local=testspec_html_dir) + brief = para_text(memberdef.find("briefdescription/para"), links) dd = memberdef.find("detaileddescription") test_id = "" @@ -334,10 +385,10 @@ def parse_memberdef( status = "active" elif "test_obsolete" in xid: status = "obsolete" - detail_lines = detail_rst_lines(dd) + detail_lines = detail_rst_lines(dd, links) see_sect = dd.find(".//simplesect[@kind='see']") if see_sect is not None: - see_rst = see_to_rst(see_sect, api_html_dir) + see_rst = see_to_rst(see_sect, api_html_dir, testspec_html_dir) # Doxygen's native `\verifies` (1.16+): a child of the memberdef # itself, not of the description, with the UID only in each requirement's @@ -357,7 +408,7 @@ def parse_memberdef( body_sections: list[list[str]] = [] if ibd is not None: for ss in ibd.findall("para/simplesect[@kind='par']"): - section_lines = section_to_rst(ss) + section_lines = section_to_rst(ss, links) if section_lines: body_sections.append(section_lines) diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index 77ba908..1325b4f 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -131,6 +131,7 @@ def build_procedure_need_rst( ): """Build a test_procedure needs item for one shared test procedure.""" from doxygen_parser import ( + RefLinks, detail_rst_lines, extract_params, para_text, @@ -140,6 +141,7 @@ def build_procedure_need_rst( name = memberdef.findtext("name", "").strip() need_id = f"test-proc-{proc_group_name}-{name}" + # No links here: the brief is only used as the title, which is not parsed. brief = para_text(memberdef.find(".//briefdescription/para")) title = (brief[:90] + "…") if len(brief) > 90 else brief if not title: @@ -163,13 +165,14 @@ def build_procedure_need_rst( params = [] see_rst_str = "" if dd is not None: - params = extract_params(dd) + links = RefLinks(api=api_html_dir, local=testspec_html_dir) + params = extract_params(dd, links) # Was a second, hand-rolled copy of the same paragraph walk, carrying the # same list-dropping defect. One helper now, so a fix lands in both. - detail_lines = detail_rst_lines(dd) + detail_lines = detail_rst_lines(dd, links) see_sect = dd.find(".//simplesect[@kind='see']") if see_sect is not None: - see_rst_str = see_to_rst(see_sect, api_html_dir) + see_rst_str = see_to_rst(see_sect, api_html_dir, testspec_html_dir) lines = [] lines.append(f".. {_need_name(need_names, 'procedure')}:: {title}") From 45a11407898a6b7dc060be15923c6f00239cd7c8 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sun, 27 Sep 2026 17:51:10 +0200 Subject: [PATCH 4/4] fix: doxygen: read autolinked @testid/@reqref values Once a tag file in TAGFILES declares a requirement of the same name (a `doxygen_tag:` needs tag, say), Doxygen autolinks an identifier-shaped UID such as DUTY_001 inside the xrefitem: `DUTY_001`. The parser read `para.text`, which is then empty, so the test case silently lost its requirement links (or its test id). Read itertext() instead. Hyphenated UIDs are never autolinked, which is why the Zephyr tree never showed it. Found by the zdocs-tests step-30 fixture (test_28 regressions). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .../_extensions/_tests/test_doxygen_parser.py | 21 +++++++++++++++++++ sphinx/_extensions/doxygen_parser.py | 12 +++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index 65f75cd..2308851 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -236,6 +236,27 @@ def test_parse_memberdef_extracts_reqrefs(): assert "zep-srs-20-1" in info["req_ids"] +def test_parse_memberdef_reads_autolinked_xrefitem_values(): + # With a needs tag in TAGFILES, Doxygen autolinks an identifier-shaped UID + # inside the xrefitem; the value must survive the wrapper. + xref = ( + "" + "Test ID" + "TC_ONE " + "" + "" + "" + "Requirement Refs" + "DUTY_001 " + "" + ) + md = _make_memberdef(extra_xrefsects=xref) + info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") + assert info["test_id"] == "TC_ONE" + assert info["req_ids"] == ["DUTY_001"] + + def test_parse_memberdef_extracts_native_verifies(): md = _make_memberdef(member_extra=_verifies("ZEP-SRS-20-6", "ZEP-SRS-20-7")) info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html") diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index 0e85725..b64faa2 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -371,14 +371,22 @@ def parse_memberdef( # the recommended way to write a test case. `build_procedure_need_rst` # already searched with `.//` for its see-also, so this also removes an # inconsistency between the two builders. + # + # The value is read with itertext(), not .text: once any tag file in + # TAGFILES declares a requirement of that name (a `doxygen_tag:` needs + # tag, say), Doxygen autolinks an identifier-shaped UID such as + # DUTY_001 inside the xrefitem, `DUTY_001`, + # and .text is then empty. Hyphenated UIDs are never autolinked, which + # is why this stayed hidden. for xrefsect in dd.iter("xrefsect"): xid = xrefsect.get("id", "") - xdesc = (xrefsect.findtext("xrefdescription/para") or "").strip() + xpara = xrefsect.find("xrefdescription/para") + xdesc = "".join(xpara.itertext()).strip() if xpara is not None else "" if "testids" in xid: test_id = xdesc elif "reqrefs" in xid: for _p in xrefsect.findall("xrefdescription/para"): - _rid = (_p.text or "").strip() + _rid = "".join(_p.itertext()).strip() if _rid: req_ids.append(_rid) elif "test_active" in xid: