Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions cmake/check_doxygen_warnings.cmake
Original file line number Diff line number Diff line change
@@ -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 ``<requirement refid="requirement_<UID>">``
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()
31 changes: 30 additions & 1 deletion cmake/doxygen.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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)
#
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions cmake/zdocs.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions doc/api/cmake/modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions doc/manual/reference/consumer-contract.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 117 additions & 1 deletion sphinx/_extensions/_tests/test_doxygen_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<memberdef kind='function' id='group__queue__api_1a001'>"
f"<name>test_queue_put</name>"
f"<briefdescription><para>Test queue put.</para></briefdescription>"
f"<detaileddescription><para>{extra_xrefsects}</para></detaileddescription>"
f"<inbodydescription>{inbody}</inbodydescription>"
f"{member_extra}"
f"<location file='test_queue.c' line='42' bodyfile='test_queue.c' bodystart='42'/>"
f"</memberdef>"
)


def _verifies(*uids):
"""Doxygen 1.16's native `\\verifies` output: a <verifies> child of the
memberdef, one <requirement> per UID, the UID only in its refid."""
reqs = "".join(f"<requirement refid='requirement_{uid}'/>" for uid in uids)
return f"<verifies>{reqs}</verifies>"


def test_parse_memberdef_extracts_testid():
xref = (
"<xrefsect id='testids_1testids'>"
Expand All @@ -228,6 +236,57 @@ 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 <ref> wrapper.
xref = (
"<xrefsect id='testids_1testids'>"
"<xreftitle>Test ID</xreftitle>"
"<xrefdescription><para><ref refid='x' kindref='member'>TC_ONE</ref> "
"</para></xrefdescription>"
"</xrefsect>"
"<xrefsect id='reqrefs_1reqrefs'>"
"<xreftitle>Requirement Refs</xreftitle>"
"<xrefdescription><para><ref refid='requirements_1DUTY_001' "
"kindref='member'>DUTY_001</ref> </para></xrefdescription>"
"</xrefsect>"
)
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")
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 = (
"<xrefsect id='reqrefs_1reqrefs'>"
"<xreftitle>Requirement Refs</xreftitle>"
"<xrefdescription><para>ZEP-SRS-20-1</para></xrefdescription>"
"</xrefsect>"
)
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 <requirement> children carry a UID; anything else Doxygen may put
# there is not a requirement link.
md = _make_memberdef(member_extra="<verifies><ref refid='x'>x</ref></verifies>")
info = dp.parse_memberdef(md, "group__queue__api", "/testspec/html", "/api/html")
assert info["req_ids"] == []


def test_parse_memberdef_active_status():
xref = (
"<xrefsect id='test_active_1test_active'>"
Expand Down Expand Up @@ -408,3 +467,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 = (
"<ref refid='group__fifo__apis_1ga1e2c' kindref='member' "
"external='/deploy/html/api/doxygen.tag'>k_fifo_get()</ref>"
)
LOCAL_REF = "<ref refid='group__procs_1ga3f93' kindref='member'>get_scratch_packet()</ref>"


def test_para_text_links_external_symbol_into_api_doxygen():
para = ET.fromstring(f"<para>Call {EXT_REF} now.</para>")
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"<para>Call {LOCAL_REF}.</para>")
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"<para>Call {EXT_REF}.</para>")
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(
"<memberdef kind='function' id='group__s_1a1'><name>test_x</name>"
f"<briefdescription><para>Verify {EXT_REF}.</para></briefdescription>"
f"<detaileddescription><para>Details {EXT_REF}.</para></detaileddescription>"
"<inbodydescription><para><simplesect kind='par'><title>Act</title>"
f"<para><orderedlist><listitem><para>Call {EXT_REF}.</para></listitem></orderedlist></para>"
"</simplesect></para></inbodydescription>"
"<location file='t.c' line='1'/></memberdef>"
)
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"<simplesect kind='see'><para>{LOCAL_REF}</para></simplesect>")
assert "../testspec/group__procs.html#ga3f93" in dp.see_to_rst(see, API, SPEC)
58 changes: 58 additions & 0 deletions sphinx/_extensions/_tests/test_doxygen_warn_gate.py
Original file line number Diff line number Diff line change
@@ -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
Loading