From 4c59163b288ac7fbd66c70335677b9bfea505190 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 31 Jul 2026 15:49:31 +0200 Subject: [PATCH] feat: report cross-module compatibility findings Refs #674 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bzl/mount_rules.bzl | 1 + docs/how-to/cross_module_compatibility.rst | 51 +++ docs/how-to/index.rst | 1 + .../score_cross_module_compatibility/BUILD | 23 ++ .../__init__.py | 327 ++++++++++++++++++ .../tests/test_compatibility.py | 224 ++++++++++++ src/extensions/score_metamodel/BUILD | 1 + src/extensions/score_metamodel/__init__.py | 11 +- .../score_metamodel/checks/check_options.py | 10 +- src/extensions/score_metamodel/log.py | 31 +- src/extensions/score_mounts/_resolver.py | 2 + .../score_mounts/tests/test_resolver.py | 2 + src/extensions/score_sphinx_bundle/BUILD | 1 + .../score_sphinx_bundle/__init__.py | 1 + src/tests/docs_bzl/README.md | 7 +- src/tests/docs_bzl/cross_module_fixture/BUILD | 18 + .../cross_module_fixture/MODULE.bazel | 16 + .../cross_module_fixture/docs/index.rst | 21 ++ .../scenarios/local_version_mismatch/BUILD | 18 + .../local_version_mismatch/docs/conf.py | 20 ++ .../local_version_mismatch/docs/index.rst | 27 ++ .../docs/metamodel.yaml | 29 ++ .../test_cross_module_compatibility.py | 142 ++++++++ src/tests/docs_bzl/test_external_bundle.py | 6 + 24 files changed, 975 insertions(+), 15 deletions(-) create mode 100644 docs/how-to/cross_module_compatibility.rst create mode 100644 src/extensions/score_cross_module_compatibility/BUILD create mode 100644 src/extensions/score_cross_module_compatibility/__init__.py create mode 100644 src/extensions/score_cross_module_compatibility/tests/test_compatibility.py create mode 100644 src/tests/docs_bzl/cross_module_fixture/BUILD create mode 100644 src/tests/docs_bzl/cross_module_fixture/MODULE.bazel create mode 100644 src/tests/docs_bzl/cross_module_fixture/docs/index.rst create mode 100644 src/tests/docs_bzl/scenarios/local_version_mismatch/BUILD create mode 100644 src/tests/docs_bzl/scenarios/local_version_mismatch/docs/conf.py create mode 100644 src/tests/docs_bzl/scenarios/local_version_mismatch/docs/index.rst create mode 100644 src/tests/docs_bzl/scenarios/local_version_mismatch/docs/metamodel.yaml create mode 100644 src/tests/docs_bzl/test_cross_module_compatibility.py diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index aa710795d..6ad2696f1 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -29,6 +29,7 @@ def _mounts_manifest_impl(ctx): "attach_to": entry.attach_to, "entry_doc": entry.entry_doc, "external": entry.external, + "repository": entry.repository, }) out = ctx.actions.declare_file(ctx.label.name + ".json") diff --git a/docs/how-to/cross_module_compatibility.rst b/docs/how-to/cross_module_compatibility.rst new file mode 100644 index 000000000..d5ffc568f --- /dev/null +++ b/docs/how-to/cross_module_compatibility.rst @@ -0,0 +1,51 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Cross-Module Compatibility Findings +=================================== + +When documentation bundles from independently released Bazel modules are +mounted together, the complete build uses one Sphinx-Needs and metamodel +baseline. A supplier can therefore be valid on its own release baseline while +an integrated build detects a newer requirement. + +Docs-as-code keeps standalone builds strict. Cross-module compatibility is +also strict by default: no finding is downgraded unless its category is enabled +explicitly in ``conf.py``: + +.. code-block:: python + + score_cross_module_compatibility_allow_missing_mandatory_attributes = True + score_cross_module_compatibility_allow_missing_mandatory_links = True + score_cross_module_compatibility_allow_version_mismatches = True + +Each switch applies only to needs owned by mounted or imported modules. Local +findings remain fatal. The switches enable these non-fatal findings: + +* missing mandatory attributes; +* missing mandatory links; and +* an exact need-link version condition such as ``target[version==1]`` that does + not match when at least one link endpoint is external. + +All other metamodel findings, all-local links, and every malformed or failed +Sphinx-Needs link condition remain fatal. + +Reports +------- + +The HTML landing page displays a warning when findings exist. Every successful +HTML build also writes these files into its output directory: + +``compatibility-findings.json`` + Machine-readable summary and findings for CI. A finding contains the owning + module, source need, optional target need, category and message. + +``compatibility-findings.html`` + Human-readable detailed report grouped by supplying module. + +Treat findings as upgrade work: they make integration possible during a version +skew, but they do not make the underlying mismatch disappear. diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index cb2d38a29..7fe107b9e 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -26,6 +26,7 @@ Here you find practical guides on how to use docs-as-code. setup write_docs other_modules + cross_module_compatibility dashboards_and_quality_gates source_to_doc_links test_to_doc_links diff --git a/src/extensions/score_cross_module_compatibility/BUILD b/src/extensions/score_cross_module_compatibility/BUILD new file mode 100644 index 000000000..afcbae665 --- /dev/null +++ b/src/extensions/score_cross_module_compatibility/BUILD @@ -0,0 +1,23 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@aspect_rules_py//py:defs.bzl", "py_library") +load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") +load("//:score_pytest.bzl", "score_pytest") + +py_library( + name = "score_cross_module_compatibility", + srcs = ["__init__.py"], + imports = ["."], + visibility = ["//visibility:public"], + deps = all_requirements + ["@score_docs_as_code//src/helper_lib"], +) + +score_pytest( + name = "unit_tests", + srcs = ["tests/test_compatibility.py"], + pytest_config = "//:pyproject.toml", + deps = [":score_cross_module_compatibility"], +) diff --git a/src/extensions/score_cross_module_compatibility/__init__.py b/src/extensions/score_cross_module_compatibility/__init__.py new file mode 100644 index 000000000..dc4d15323 --- /dev/null +++ b/src/extensions/score_cross_module_compatibility/__init__.py @@ -0,0 +1,327 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Compatibility reporting at documentation-module boundaries. + +The metamodel defines valid needs. This extension owns the separate policy for +integrating independently released documentation modules. +""" + +from __future__ import annotations + +import html +import json +import os +import re +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import Any + +from sphinx.application import Sphinx +from sphinx.util import logging +from sphinx_needs.need_item import NeedItem + +from src.helper_lib import find_ws_root, get_runfiles_dir + +_VERSION_CONDITION = re.compile(r"^\s*version\s*==\s*(\d+)\s*$") +logger = logging.getLogger(__name__) + +MANDATORY_ATTRIBUTE = "mandatory-attribute" +MANDATORY_LINK = "mandatory-link" +VERSION_MISMATCH = "version-mismatch" + +_CONFIG_BY_CATEGORY = { + MANDATORY_ATTRIBUTE: "score_cross_module_compatibility_allow_missing_mandatory_attributes", + MANDATORY_LINK: "score_cross_module_compatibility_allow_missing_mandatory_links", + VERSION_MISMATCH: "score_cross_module_compatibility_allow_version_mismatches", +} + + +@dataclass(frozen=True) +class ExternalMount: + mount_at: str + module: str + + +@dataclass(frozen=True) +class CompatibilityFinding: + module: str + need_id: str + source: str + category: str + message: str + target_id: str = "" + target_module: str = "" + + +class CompatibilityReporter: + """Collect compatibility findings and identify needs owned by external modules.""" + + def __init__( + self, + mounts: list[ExternalMount], + enabled_categories: frozenset[str] = frozenset(), + ): + self._mounts = mounts + self._enabled_categories = enabled_categories + self._findings: dict[CompatibilityFinding, None] = {} + + @classmethod + def from_manifest( + cls, + manifest_path: str | Path | None, + enabled_categories: frozenset[str] = frozenset(), + ) -> CompatibilityReporter: + if not manifest_path: + return cls([], enabled_categories) + try: + raw: dict[str, object] = json.loads( + Path(manifest_path).read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + return cls([], enabled_categories) + entries = raw.get("mounts", []) + if not isinstance(entries, list): + return cls([], enabled_categories) + mounts: list[ExternalMount] = [] + for entry in entries: + if not isinstance(entry, dict) or not entry.get("external"): + continue + mount_at = entry.get("mount_at") + if not isinstance(mount_at, str) or not mount_at: + continue + repository = entry.get("repository", "external") + mounts.append(ExternalMount(mount_at.strip("/"), str(repository))) + return cls(mounts, enabled_categories) + + def module_for_need(self, need: NeedItem | dict[str, Any]) -> str | None: + if bool(need.get("is_external", False)): + # Imported needs.json entries are external even when their source + # directory is unavailable in this build. The URL is stable enough + # for the report; use it as a conservative module identifier. + url = need.get("external_url") + return str(url) if url else "external-needs" + docname = need.get("docname") + if not isinstance(docname, str): + return None + normalized = docname.strip("/") + candidates = [ + mount + for mount in self._mounts + if normalized == mount.mount_at + or normalized.startswith(mount.mount_at + "/") + ] + return ( + max(candidates, key=lambda mount: len(mount.mount_at)).module + if candidates + else None + ) + + def record( + self, + need: NeedItem | dict[str, Any], + category: str, + message: str, + source: str, + *, + target: NeedItem | dict[str, Any] | None = None, + ) -> bool: + if category not in self._enabled_categories: + return False + module = self.module_for_need(need) + target_module = self.module_for_need(target) if target is not None else None + owner = module or target_module + if owner is None: + return False + finding = CompatibilityFinding( + owner, + str(need.get("id", "")), + source, + category, + message, + str(target.get("id", "")) if target is not None else "", + target_module or "", + ) + if finding in self._findings: + return True + self._findings[finding] = None + logger.info( + "Compatibility finding: module=%s need=%s source=%s category=%s%s: %s", + owner, + need.get("id", ""), + source, + category, + f" target={target.get('id', '')}" if target is not None else "", + message, + ) + return True + + @property + def findings(self) -> list[CompatibilityFinding]: + return sorted( + self._findings, + key=lambda item: (item.module, item.need_id, item.target_id, item.message), + ) + + def document(self) -> dict[str, object]: + findings = self.findings + return { + "summary": { + "count": len(findings), + "modules": len({item.module for item in findings}), + }, + "findings": [asdict(item) for item in findings], + } + + def write(self, outdir: str | Path) -> None: + outdir_path = Path(outdir) + outdir_path.joinpath("compatibility-findings.json").write_text( + json.dumps(self.document(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + groups: dict[str, list[CompatibilityFinding]] = {} + for finding in self.findings: + groups.setdefault(finding.module, []).append(finding) + sections = ( + "".join( + "

" + + html.escape(module) + + "

" + for module, items in groups.items() + ) + or "

No compatibility findings.

" + ) + outdir_path.joinpath("compatibility-findings.html").write_text( + 'Compatibility findings' + "

Compatibility findings

" + + sections + + "\n", + encoding="utf-8", + ) + + +def _manifest_path(app: Sphinx) -> Path | None: + raw = getattr(app.config, "mounts_manifest", "") or os.environ.get( + "MOUNTS_MANIFEST", "" + ) + if not isinstance(raw, str) or not raw.strip(): + return None + direct = Path(raw) + runfiles = get_runfiles_dir() / raw + # ``mounts_manifest`` may be an execroot path, while the environment value + # passed to ``bazel run`` is runfiles-relative. Prefer an existing path so + # the policy does not depend on the current working directory. + if direct.is_file(): + return direct + if runfiles.is_file(): + return runfiles + return runfiles if find_ws_root() else direct + + +def get_reporter(app: Sphinx) -> CompatibilityReporter: + reporter = getattr(app, "_score_compatibility_reporter", None) + if not isinstance(reporter, CompatibilityReporter): + enabled_categories = frozenset( + category + for category, config_name in _CONFIG_BY_CATEGORY.items() + if bool(getattr(app.config, config_name, False)) + ) + reporter = CompatibilityReporter.from_manifest( + _manifest_path(app), enabled_categories + ) + app._score_compatibility_reporter = reporter # pyright: ignore[reportAttributeAccessIssue] + return reporter + + +def _need_location(need: NeedItem) -> str: + return ( + f"{need.get('docname', '')}{need.get('doctype', '')}:{need.get('lineno', '')}" + ) + + +def classify_version_conditions(app: Sphinx, needs: dict[str, NeedItem]) -> None: + """Downgrade only eligible cross-module exact-version link mismatches. + + This event runs immediately before Sphinx-Needs resolves conditions. A + classified condition is removed after being recorded, so its normal + backlink and rendered relationship remain available without a fatal + Sphinx-Needs warning. All other conditions remain untouched. + """ + reporter = get_reporter(app) + for need in needs.values(): + for _, links in need.iter_links_items(as_str=False): + for index, link in enumerate(links): + match = _VERSION_CONDITION.fullmatch(link.condition or "") + target = needs.get(link.id) + if match is None or target is None: + continue + expected = match.group(1) + actual = str(target.get("version", "")) + if actual == expected or not reporter.record( + need, + VERSION_MISMATCH, + f"requires target version {expected}, but target is version {actual or 'unset'}", + _need_location(need), + target=target, + ): + continue + links[index] = replace(link, condition=None) + + +def _inject_notice( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, object], + doctree: object, +) -> None: + if pagename != "index": + return + findings = get_reporter(app).findings + body = context.get("body") + if findings and isinstance(body, str): + context["body"] = ( + '

' + "External documentation compatibility findings

" + f"{len(findings)} finding(s) require review; " + 'open the report.

' + + body + ) + + +def _write_report(app: Sphinx, exception: Exception | None) -> None: + if exception is None: + get_reporter(app).write(app.outdir) + + +def setup(app: Sphinx) -> dict[str, object]: + for config_name in _CONFIG_BY_CATEGORY.values(): + app.add_config_value(config_name, False, rebuild="env") + + if app.config.score_cross_module_compatibility_allow_version_mismatches: + app.connect("needs-before-post-processing", classify_version_conditions) + + if any( + bool(getattr(app.config, config_name)) + for config_name in _CONFIG_BY_CATEGORY.values() + ): + app.connect("html-page-context", _inject_notice) + + app.connect("build-finished", _write_report) + + return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/src/extensions/score_cross_module_compatibility/tests/test_compatibility.py b/src/extensions/score_cross_module_compatibility/tests/test_compatibility.py new file mode 100644 index 000000000..3465ac30c --- /dev/null +++ b/src/extensions/score_cross_module_compatibility/tests/test_compatibility.py @@ -0,0 +1,224 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import json +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +from score_cross_module_compatibility import ( + MANDATORY_ATTRIBUTE, + MANDATORY_LINK, + VERSION_MISMATCH, + CompatibilityReporter, + classify_version_conditions, + get_reporter, + setup, +) +from sphinx.application import Sphinx +from sphinx_needs.need_item import NeedItem, NeedLink + + +class FakeNeed(dict[str, object]): + def iter_links_items(self, *, as_str: bool = False): + assert not as_str + yield "links", self["links"] + + +class FakeSphinx: + def __init__(self, allow_version_mismatches: bool): + self.config = SimpleNamespace( + score_cross_module_compatibility_allow_version_mismatches=allow_version_mismatches + ) + self.connections: list[tuple[str, object]] = [] + + def add_config_value(self, name: str, default: object, rebuild: str) -> None: + if not hasattr(self.config, name): + setattr(self.config, name, default) + + def connect(self, event: str, handler: object) -> None: + self.connections.append((event, handler)) + + +def _classify(app: SimpleNamespace, needs: dict[str, FakeNeed]) -> None: + classify_version_conditions(cast(Sphinx, app), cast(dict[str, NeedItem], needs)) + + +def _links(need: FakeNeed) -> list[NeedLink]: + return cast(list[NeedLink], need["links"]) + + +def test_reporter_records_external_owner_and_writes_report(tmp_path: Path) -> None: + manifest = tmp_path / "mounts.json" + manifest.write_text( + json.dumps( + { + "mounts": [ + {"mount_at": "process", "external": True, "repository": "process+"} + ] + } + ) + ) + reporter = CompatibilityReporter.from_manifest( + manifest, frozenset({VERSION_MISMATCH}) + ) + origin = {"id": "req__consumer", "docname": "index"} + target = {"id": "req__producer", "docname": "process/index"} + + assert reporter.record( + origin, VERSION_MISMATCH, "version differs", "index.rst:12", target=target + ) + reporter.write(tmp_path) + + document = json.loads((tmp_path / "compatibility-findings.json").read_text()) + assert document["summary"] == {"count": 1, "modules": 1} + assert document["findings"][0]["target_id"] == "req__producer" + + +def test_setup_skips_version_precheck_when_version_compatibility_is_disabled() -> None: + app = FakeSphinx(allow_version_mismatches=False) + + setup(app) # type: ignore[arg-type] + + assert {event for event, _ in app.connections} == {"build-finished"} + + +def test_setup_registers_version_precheck_when_version_compatibility_is_enabled() -> ( + None +): + app = FakeSphinx(allow_version_mismatches=True) + + setup(app) # type: ignore[arg-type] + + assert ( + "needs-before-post-processing", + classify_version_conditions, + ) in app.connections + assert {event for event, _ in app.connections} == { + "needs-before-post-processing", + "html-page-context", + "build-finished", + } + + +def test_setup_registers_reporting_hooks_for_non_version_compatibility() -> None: + app = FakeSphinx(allow_version_mismatches=False) + app.config.score_cross_module_compatibility_allow_missing_mandatory_attributes = ( + True + ) + + setup(app) # type: ignore[arg-type] + + assert ( + "needs-before-post-processing", + classify_version_conditions, + ) not in app.connections + assert {event for event, _ in app.connections} == { + "html-page-context", + "build-finished", + } + + +def test_reporter_requires_each_category_to_be_enabled(tmp_path: Path) -> None: + manifest = tmp_path / "mounts.json" + manifest.write_text( + json.dumps( + { + "mounts": [ + {"mount_at": "process", "external": True, "repository": "process+"} + ] + } + ) + ) + reporter = CompatibilityReporter.from_manifest( + manifest, frozenset({MANDATORY_ATTRIBUTE}) + ) + need = {"id": "req__producer", "docname": "process/index"} + + assert reporter.record( + need, MANDATORY_ATTRIBUTE, "missing attribute", "process/index.rst:12" + ) + assert not reporter.record( + need, MANDATORY_LINK, "missing link", "process/index.rst:12" + ) + assert not reporter.record( + need, VERSION_MISMATCH, "version differs", "process/index.rst:12" + ) + + +def test_cross_module_version_condition_is_reported_and_neutralized( + tmp_path: Path, +) -> None: + manifest = tmp_path / "mounts.json" + manifest.write_text( + json.dumps( + { + "mounts": [ + {"mount_at": "process", "external": True, "repository": "process+"} + ] + } + ) + ) + app = SimpleNamespace( + config=SimpleNamespace( + mounts_manifest=str(manifest), + score_cross_module_compatibility_allow_version_mismatches=True, + ) + ) + origin = FakeNeed( + id="req__consumer", + docname="index", + doctype=".rst", + lineno=12, + links=[NeedLink(id="req__producer", condition="version==1")], + ) + target = FakeNeed( + id="req__producer", docname="process/index", version="2", links=[] + ) + + _classify(app, {"req__consumer": origin, "req__producer": target}) + + assert _links(origin)[0].condition is None + assert len(get_reporter(cast(Sphinx, app)).findings) == 1 + + +def test_local_version_condition_remains_strict(tmp_path: Path) -> None: + app = SimpleNamespace(config=SimpleNamespace(mounts_manifest="")) + origin = FakeNeed( + id="req__one", + docname="index", + links=[NeedLink(id="req__two", condition="version == 1")], + ) + target = FakeNeed(id="req__two", docname="other", version="2", links=[]) + + _classify(app, {"req__one": origin, "req__two": target}) + + assert _links(origin)[0].condition == "version == 1" + + +def test_external_version_condition_remains_strict_by_default(tmp_path: Path) -> None: + manifest = tmp_path / "mounts.json" + manifest.write_text( + json.dumps( + { + "mounts": [ + {"mount_at": "process", "external": True, "repository": "process+"} + ] + } + ) + ) + app = SimpleNamespace(config=SimpleNamespace(mounts_manifest=str(manifest))) + origin = FakeNeed( + id="req__consumer", + docname="index", + links=[NeedLink(id="req__producer", condition="version == 1")], + ) + target = FakeNeed( + id="req__producer", docname="process/index", version="2", links=[] + ) + + _classify(app, {"req__consumer": origin, "req__producer": target}) + + assert _links(origin)[0].condition == "version == 1" diff --git a/src/extensions/score_metamodel/BUILD b/src/extensions/score_metamodel/BUILD index 7b421e3f1..be9c61605 100644 --- a/src/extensions/score_metamodel/BUILD +++ b/src/extensions/score_metamodel/BUILD @@ -56,6 +56,7 @@ py_library( visibility = ["//visibility:public"], # TODO: Figure out if all requirements are needed or if we can break it down a bit deps = all_requirements + [ + "@score_docs_as_code//src/extensions/score_cross_module_compatibility", "@score_docs_as_code//src/extensions/score_metrics", "@score_docs_as_code//src/helper_lib", ], diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 8b4b63f2a..ee8f645b3 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -16,6 +16,7 @@ from collections.abc import Callable from pathlib import Path +from score_cross_module_compatibility import get_reporter from sphinx.application import Sphinx from sphinx_needs import logging from sphinx_needs.data import NeedsView, SphinxNeedsData @@ -94,11 +95,7 @@ def graph_check(func: graph_check_function): return func -def _run_checks(app: Sphinx, exception: Exception | None) -> None: - # Do not run checks if an exception occurred during build - if exception: - return - +def _run_checks(app: Sphinx) -> None: # First of all postprocess the need links to convert # type names into actual need types. # This must be done before any checks are run. @@ -116,7 +113,7 @@ def _run_checks(app: Sphinx, exception: Exception | None) -> None: cwd_or_ws_root = Path(ws_root) if ws_root else Path.cwd() prefix = str(Path(app.srcdir).relative_to(cwd_or_ws_root)) - log = CheckLogger(logger, prefix) + log = CheckLogger(logger, prefix, get_reporter(app)) checks_filter = parse_checks_filter(app.config.score_metamodel_checks) @@ -287,7 +284,7 @@ def setup(app: Sphinx) -> dict[str, str | bool]: ), ) - _ = app.connect("build-finished", _run_checks) + _ = app.connect("write-started", lambda app, _builder: _run_checks(app)) return { "version": "0.1", diff --git a/src/extensions/score_metamodel/checks/check_options.py b/src/extensions/score_metamodel/checks/check_options.py index 1aec5f551..0780594fb 100644 --- a/src/extensions/score_metamodel/checks/check_options.py +++ b/src/extensions/score_metamodel/checks/check_options.py @@ -87,7 +87,9 @@ def _validate(attributes_to_allowed_values: dict[str, str], mandatory: bool): values = _get_normalized(need, attribute) if mandatory and not values: log.warning_for_need( - need, f"is missing required attribute: `{attribute}`." + need, + f"is missing required attribute: `{attribute}`.", + category="mandatory-attribute", ) for value in values: @@ -178,7 +180,11 @@ def _validate( for attribute, allowed_values in attributes_to_allowed_values.items(): values = _get_normalized(need, attribute) if mandatory and not values: - log.warning_for_need(need, f"is missing required link: `{attribute}`.") + log.warning_for_need( + need, + f"is missing required link: `{attribute}`.", + category="mandatory-link", + ) allowed_regex = "|".join(_to_link_pattern(v) for v in allowed_values) diff --git a/src/extensions/score_metamodel/log.py b/src/extensions/score_metamodel/log.py index 699ba51bf..ae0088ac2 100644 --- a/src/extensions/score_metamodel/log.py +++ b/src/extensions/score_metamodel/log.py @@ -14,6 +14,7 @@ from typing import Any from docutils.nodes import Node +from score_cross_module_compatibility import CompatibilityReporter from sphinx_needs import logging from sphinx_needs.logging import SphinxLoggerAdapter from sphinx_needs.need_item import NeedItem @@ -24,12 +25,18 @@ class CheckLogger: - def __init__(self, log: SphinxLoggerAdapter, prefix: str): + def __init__( + self, + log: SphinxLoggerAdapter, + prefix: str, + compatibility: CompatibilityReporter | None = None, + ): self._log = log self._info_count = 0 self._warning_count = 0 self._prefix = prefix self._new_checks: list[NewCheck] = [] + self._compatibility = compatibility @staticmethod def _location(need: NeedItem, prefix: str): @@ -53,7 +60,7 @@ def warning_for_option( ): full_msg = f"{need['id']}.{option} ({need.get(option, None)}): {msg}" location = CheckLogger._location(need, self._prefix) - self._log_message(full_msg, location, is_new_check) + self._log_message(full_msg, location, is_new_check, need) def warning_for_link( self, @@ -73,19 +80,33 @@ def warning_for_link( # if allowed_regex: # msg += f" (allowed pattern: `{allowed_regex}`)" - self.warning_for_need(need, msg, is_new_check=is_new_check) + self.warning_for_need(need, msg, is_new_check=is_new_check, category="link") - def warning_for_need(self, need: NeedItem, msg: str, is_new_check: bool = False): + def warning_for_need( + self, + need: NeedItem, + msg: str, + is_new_check: bool = False, + category: str = "need", + ): full_msg = f"{need['id']}: {msg}" location = CheckLogger._location(need, self._prefix) - self._log_message(full_msg, location, is_new_check) + self._log_message(full_msg, location, is_new_check, need, category) def _log_message( self, msg: str, location: Location, is_new_check: bool = False, + need: NeedItem | None = None, + category: str = "metamodel", ): + if need is not None and self._compatibility is not None: + source = ( + location if isinstance(location, str) else str(need.get("docname", "")) + ) + if self._compatibility.record(need, category, msg, source): + return if is_new_check: self._new_checks.append((msg, location)) self._info_count += 1 diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index a26713992..a13a85aec 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -35,6 +35,7 @@ class MountSpec: attach_to: str | None = None entry_doc: str = "index" external: bool = False + repository: str = "" @dataclass(frozen=True) @@ -78,6 +79,7 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: if entry.get("entry_doc") else "index", external=bool(entry.get("external", False)), + repository=str(entry.get("repository", "")), ) ) return MountsManifest( diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index d5fdff2b2..f01338ff2 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -95,6 +95,7 @@ def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path) -> "runtime_path": "../score_process+/docs_as_mount", "mount_at": "process", "external": True, + "repository": "score_process+", }, ], }, @@ -103,6 +104,7 @@ def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path) -> assert specs[0].src_root == "src/docs" assert specs[1].src_root == "external/score_process+/docs_as_mount" assert specs[1].external is True + assert specs[1].repository == "score_process+" def test_load_missing_required_key_raises(tmp_path: Path) -> None: diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD index a941ffe68..954c172e3 100644 --- a/src/extensions/score_sphinx_bundle/BUILD +++ b/src/extensions/score_sphinx_bundle/BUILD @@ -27,6 +27,7 @@ py_library( "@score_docs_as_code//src/extensions:score_plantuml", "@score_docs_as_code//src/extensions:broken_link_fix", "@score_docs_as_code//src/extensions/score_draw_uml_funcs", + "@score_docs_as_code//src/extensions/score_cross_module_compatibility", "@score_docs_as_code//src/extensions/score_layout", "@score_docs_as_code//src/extensions/score_metamodel", "@score_docs_as_code//src/extensions/score_mounts", diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py index b0d84da5a..acbd9d15f 100644 --- a/src/extensions/score_sphinx_bundle/__init__.py +++ b/src/extensions/score_sphinx_bundle/__init__.py @@ -21,6 +21,7 @@ "sphinxcontrib.plantuml", "score_plantuml", "sphinx_needs", + "score_cross_module_compatibility", "score_metamodel", "sphinx_design", "myst_parser", diff --git a/src/tests/docs_bzl/README.md b/src/tests/docs_bzl/README.md index 7c7a6ea02..cb6c7b313 100644 --- a/src/tests/docs_bzl/README.md +++ b/src/tests/docs_bzl/README.md @@ -10,7 +10,8 @@ These tests run **outside** Bazel with pytest and drive the public `docs.bzl` macros through real `bazel run` / `bazel build` commands. They cover exactly -what a consumer invokes: `docs()`, `docs_bundle()`, mounts, and failure cases. +what a consumer invokes: `docs()`, `docs_bundle()`, mounts, cross-module +compatibility reporting, and failure cases. ```text docs_bzl/ @@ -20,6 +21,7 @@ docs_bzl/ │ ├── metamodel_violation/ │ ├── nested_bundles/ │ ├── external_bundle/ +│ ├── local_version_mismatch/ │ └── invalid_bundle_placements/ └── test_.py ``` @@ -29,6 +31,9 @@ consumer behavior, not the Bazel mechanism used to execute it. Positive renderin uses `bazel run`; sandbox-only behavior uses `bazel build`; invalid package definitions are expected build failures. Assertions retain rendered HTML, manifest order and metadata, source links, toctree attachment, and diagnostics. +The cross-module compatibility test creates its consumer in a temporary +workspace, so this repository's production ``MODULE.bazel`` stays free of test +dependencies while the test still traverses real Bzlmod module boundaries. Note that these tests run `bazel` commands, so they are slow. They need to be executed sequentially. Use sparingly. They do not call `bazel clean`, so the persistent diff --git a/src/tests/docs_bzl/cross_module_fixture/BUILD b/src/tests/docs_bzl/cross_module_fixture/BUILD new file mode 100644 index 000000000..7cad3a958 --- /dev/null +++ b/src/tests/docs_bzl/cross_module_fixture/BUILD @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@score_docs_as_code//:docs.bzl", "docs") + +docs( + source_dir = "docs", +) diff --git a/src/tests/docs_bzl/cross_module_fixture/MODULE.bazel b/src/tests/docs_bzl/cross_module_fixture/MODULE.bazel new file mode 100644 index 000000000..6b9083d3e --- /dev/null +++ b/src/tests/docs_bzl/cross_module_fixture/MODULE.bazel @@ -0,0 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +module(name = "score_docs_compatibility_fixture", version = "0.0.1") + +bazel_dep(name = "score_docs_as_code", version = "4.6.0") diff --git a/src/tests/docs_bzl/cross_module_fixture/docs/index.rst b/src/tests/docs_bzl/cross_module_fixture/docs/index.rst new file mode 100644 index 000000000..6dce7963c --- /dev/null +++ b/src/tests/docs_bzl/cross_module_fixture/docs/index.rst @@ -0,0 +1,21 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Fixture producer +================ + +.. test_req:: External compatibility source + :id: test_req__fixture__source + :version: 1 + :links: test_req__host__target[version==1] diff --git a/src/tests/docs_bzl/scenarios/local_version_mismatch/BUILD b/src/tests/docs_bzl/scenarios/local_version_mismatch/BUILD new file mode 100644 index 000000000..d6a65cf32 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_version_mismatch/BUILD @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:docs.bzl", "docs") + +docs( + source_dir = "docs", +) diff --git a/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/conf.py b/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/conf.py new file mode 100644 index 000000000..e36e9b648 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/conf.py @@ -0,0 +1,20 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import os + +project = "Local version mismatch" +project_url = "https://example.invalid/local-version-mismatch" +extensions = ["score_sphinx_bundle"] +score_metamodel_yaml = os.path.join(os.path.dirname(__file__), "metamodel.yaml") +required_in_id = ["local"] diff --git a/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/index.rst b/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/index.rst new file mode 100644 index 000000000..42753a305 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/index.rst @@ -0,0 +1,27 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Local version mismatch +====================== + +.. test_req:: Origin + :id: test_req__local__origin + :status: valid + :version: 1 + :links: test_req__local__target[version==1] + +.. test_req:: Target + :id: test_req__local__target + :status: valid + :version: 2 diff --git a/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/metamodel.yaml b/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/metamodel.yaml new file mode 100644 index 000000000..8963734da --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_version_mismatch/docs/metamodel.yaml @@ -0,0 +1,29 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +needs_types: + test_req: + title: Test Requirement + prefix: test_req__ + parts: 3 + mandatory_options: + id: ^test_req__[0-9a-zA-Z_]*$ + status: ^(draft|valid)$ + version: ^[0-9]+$ + optional_options: + tags: .* + content: .* + template: .* + optional_links: + links: ANY +links: {} diff --git a/src/tests/docs_bzl/test_cross_module_compatibility.py b/src/tests/docs_bzl/test_cross_module_compatibility.py new file mode 100644 index 000000000..c2d428bd8 --- /dev/null +++ b/src/tests/docs_bzl/test_cross_module_compatibility.py @@ -0,0 +1,142 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Black-box coverage for a real Bzlmod documentation-module boundary.""" + +import json +import shutil +import subprocess +from pathlib import Path + +from src.tests.docs_bzl.helpers import repo_root, run_scenario + + +def _write_cross_module_consumer( + workspace: Path, compatibility_config: str = "" +) -> None: + source_root = repo_root() + fixture = source_root / "src/tests/docs_bzl/cross_module_fixture" + shutil.copyfile(source_root / ".bazelversion", workspace / ".bazelversion") + subprocess.run(["git", "init", "--quiet", str(workspace)], check=True) + workspace.joinpath("MODULE.bazel").write_text( + f'''module(name = "compatibility_consumer") +bazel_dep(name = "rules_python", version = "1.8.5") +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(is_default = True, python_version = "3.12") +bazel_dep(name = "score_docs_as_code", version = "4.6.0") +local_path_override(module_name = "score_docs_as_code", path = "{source_root}") +bazel_dep(name = "score_docs_compatibility_fixture", version = "0.0.1") +local_path_override(module_name = "score_docs_compatibility_fixture", path = "{fixture}") +''', + encoding="utf-8", + ) + workspace.joinpath("BUILD").write_text( + """load("@score_docs_as_code//:docs.bzl", "docs") +docs(source_dir = "docs", bundles = [{"bundle": "@score_docs_compatibility_fixture//:docs_bundle", "mount_at": "fixture"}]) +""", + encoding="utf-8", + ) + docs = workspace / "docs" + docs.mkdir() + docs.joinpath("conf.py").write_text( + f"""import os + +project = "Cross module compatibility consumer" +project_url = "https://example.invalid/cross-module-consumer" +extensions = ["score_sphinx_bundle"] +score_metamodel_yaml = os.path.join(os.path.dirname(__file__), "metamodel.yaml") +required_in_id = ["host", "fixture"] +{compatibility_config} +""", + encoding="utf-8", + ) + docs.joinpath("metamodel.yaml").write_text( + """needs_types: + test_req: + title: Test Requirement + prefix: test_req__ + parts: 3 + mandatory_options: + id: ^test_req__[0-9a-zA-Z_]*$ + status: ^(draft|valid)$ + version: ^[0-9]+$ + optional_options: + tags: .* + content: .* + template: .* + optional_links: + links: ANY +links: {} +""", + encoding="utf-8", + ) + docs.joinpath("index.rst").write_text( + """Cross module compatibility +========================== + +.. toctree:: + :maxdepth: 1 + +.. test_req:: Host target + :id: test_req__host__target + :status: valid + :version: 2 +""", + encoding="utf-8", + ) + + +def test_cross_module_version_mismatch_is_reported_without_failing( + tmp_path: Path, +) -> None: + _write_cross_module_consumer( + tmp_path, + "score_cross_module_compatibility_allow_missing_mandatory_attributes = True\n" + "score_cross_module_compatibility_allow_version_mismatches = True", + ) + result = subprocess.run( + ["bazel", f"--bazelrc={repo_root() / '.bazelrc'}", "run", "//:docs"], + cwd=tmp_path, + text=True, + capture_output=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "Compatibility finding:" in result.stdout + report = json.loads( + (tmp_path / "_build/compatibility-findings.json").read_text(encoding="utf-8") + ) + assert report["summary"] == {"count": 2, "modules": 1} + findings = report["findings"] + assert {finding["category"] for finding in findings} == { + "mandatory-attribute", + "version-mismatch", + } + assert {finding["need_id"] for finding in findings} == {"test_req__fixture__source"} + version_finding = next( + finding for finding in findings if finding["category"] == "version-mismatch" + ) + assert version_finding["target_id"] == "test_req__host__target" + + index = (tmp_path / "_build/index.html").read_text(encoding="utf-8") + assert "External documentation compatibility findings" in index + assert (tmp_path / "_build/compatibility-findings.html").is_file() + + +def test_external_findings_remain_fatal_by_default(tmp_path: Path) -> None: + _write_cross_module_consumer(tmp_path) + result = subprocess.run( + ["bazel", f"--bazelrc={repo_root() / '.bazelrc'}", "run", "//:docs"], + cwd=tmp_path, + text=True, + capture_output=True, + ) + + assert result.returncode != 0 + assert "is missing required attribute: `status`" in result.stderr + + +def test_local_version_mismatch_remains_fatal() -> None: + result = run_scenario("run", "local_version_mismatch", ":docs", expect_error=True) + assert "condition 'version==1' not satisfied" in result.stderr diff --git a/src/tests/docs_bzl/test_external_bundle.py b/src/tests/docs_bzl/test_external_bundle.py index 54db947bd..39f230147 100644 --- a/src/tests/docs_bzl/test_external_bundle.py +++ b/src/tests/docs_bzl/test_external_bundle.py @@ -23,6 +23,8 @@ # ******************************************************************************* """External docs_bundle() scenario.""" +import json + from src.tests.docs_bzl.helpers import run_scenario @@ -31,3 +33,7 @@ def test_external_bundle_builds_in_sandbox_and_at_runtime(): result = run_scenario("run", "external_bundle", ":docs") assert (result.build_dir / "index.html").is_file() + report = json.loads( + (result.build_dir / "compatibility-findings.json").read_text(encoding="utf-8") + ) + assert report["summary"]["count"] >= 0