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)
+ + "
"
+ + "".join(
+ "- "
+ + html.escape(item.need_id)
+ + ""
+ + (" → " + html.escape(item.target_id) if item.target_id else "")
+ + " ("
+ + html.escape(item.category)
+ + "): "
+ + html.escape(item.message)
+ + "
"
+ for item in items
+ )
+ + "
"
+ 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