diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 737dc62f0..ef55ed3df 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -64,10 +64,12 @@ DocsBundleInfo = provider( "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", "own_source_files": "This bundle's direct source files, excluding nested bundles.", "sourcelinks": "Source-code-link JSON files together with their owning repository.", - "external_runfiles": "Documentation source files not read from the workspace at runtime.", - # Bundle-owned supporting/runtime files. Unlike host-owned docs data, - # these are resolved at this bundle's mount. - "data": "Bundle-owned supporting/runtime files resolved at the bundle's mount.", + "external_runfiles": "Documentation source files from external repositories needed in runfiles.", + # Bundle-owned generated/supporting files. Both bundle data and + # docs(data = [...]) are build inputs; unlike host-owned docs data, + # these are resolved at this bundle's mount (for example, a generated + # index.rst). + "data": "Bundle-owned generated/supporting files resolved at the bundle's mount.", }, ) @@ -488,12 +490,13 @@ _bundle_source_files = rule( doc = "Exposes direct bundle sources without nested bundle sources.", ) -def bundle_source_files(name, bundle, visibility = None): +def bundle_source_files(name, bundle, visibility = None, tags = None): """Create a target containing only the direct sources of a bundle.""" _bundle_source_files( name = name, bundle = bundle, visibility = visibility, + tags = tags, ) return ":" + name diff --git a/default_conf.py.tpl b/default_conf.py.tpl index d7455cbb2..026d84720 100644 --- a/default_conf.py.tpl +++ b/default_conf.py.tpl @@ -10,13 +10,17 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# Default Sphinx configuration emitted by the ``docs()`` macro. +# Default Sphinx configuration emitted by the ``docs()`` and +# ``docs_bundle()`` macros. # SCORE Docs-as-Code owns these baseline settings. Projects needing further # Sphinx configuration can provide their own conf.py instead. project = {PROJECT} project_url = {PROJECT_URL} version = "0.0.0" +# ``docs_bundle(entry_doc = ...)`` may use a non-index entry page. The regular +# project-level docs() build uses the default value, ``index``. +master_doc = {ENTRY_DOC} # Allow feature IDs that use the Bazel module name without its first # underscore-separated prefix (for example, ``score_docs_as_code`` becomes diff --git a/docs.bzl b/docs.bzl index 46cb7622d..8c1f0d39e 100644 --- a/docs.bzl +++ b/docs.bzl @@ -11,8 +11,15 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -""" -Easy streamlined way for S-CORE docs-as-code. +"""Public Bazel macros for building and composing S-CORE documentation. + +The ``docs_bundle`` macro describes which documentation files belong to a +reusable bundle and how nested bundles are composed. Source-bearing bundles +also create a ``.__internal__.needs_local`` export containing Needs from +their own sources. This first version only supports self-contained bundles; +references to Needs defined outside the bundle remain unresolved until +cross-bundle imports are added. The top-level ``docs`` macro continues to use +its existing project-wide ``needs_json`` export. """ # Multiple approaches are available to build the same documentation output: @@ -74,7 +81,16 @@ def _module_name_without_prefix(): return "" return module_name.split("_", 1)[-1] +def _bundle_internal_target(name, target): + """Return the conventional name for a target internal to a bundle.""" + return name + ".__internal__." + target + def _generated_conf_impl(ctx): + """Generate a Sphinx config at the source-root path expected by sphinxdocs. + + ``entry_doc`` determines which document Sphinx treats as the project root + when a bundle's entry page is not named ``index``. + """ output = ctx.actions.declare_file(ctx.attr.output_path) ctx.actions.expand_template( template = ctx.file.template, @@ -83,6 +99,7 @@ def _generated_conf_impl(ctx): "{PROJECT}": repr(ctx.attr.project), "{PROJECT_URL}": repr(ctx.attr.project_url), "{REQUIRED_IN_ID}": repr([ctx.attr.required_in_id]) if ctx.attr.required_in_id else "[]", + "{ENTRY_DOC}": repr(ctx.attr.entry_doc), }, ) return [DefaultInfo(files = depset([output]))] @@ -93,6 +110,7 @@ _generated_conf = rule( "project": attr.string(mandatory = True), "project_url": attr.string(mandatory = True), "required_in_id": attr.string(mandatory = True), + "entry_doc": attr.string(default = "index"), "output_path": attr.string(mandatory = True), "template": attr.label( allow_single_file = True, @@ -114,7 +132,22 @@ def _is_needs_json_target(label): """ return str(label).rsplit(":", 1)[-1] == "needs_json" -def docs_bundle( +def _bundle_sphinx_strip_prefix(source_dir): + """Return the source prefix used by a Sphinx action for this repository.""" + source_prefix = join_path(native.package_name(), source_dir) + repository = native.repo_name() + if repository: + # External repository files use ``..//`` in short_path, + # while the main repository uses paths without that leading segment. + source_prefix = join_path( + "../" + repository, + source_prefix, + ) + if source_prefix: + source_prefix += "/" + return source_prefix + +def _declare_docs_bundle( name, source_dir = None, srcs = [], @@ -125,7 +158,7 @@ def docs_bundle( code_targets = [], visibility = None, **kwargs): - """A docs bundle, optionally composed of others. + """Declare the reusable bundle and return inputs needed by local exports. Args: name: target name. @@ -182,7 +215,9 @@ def docs_bundle( sourcelinks.append(code_targets_sourcelinks) # Store the source directory relative to the workspace so bundle consumers - # can locate the original files without copying them. + # can locate the original files without copying them. The internal rule + # keeps this path in its provider; the Needs build below uses the same + # source root so docnames and link targets remain stable. pkg = native.package_name() strip_prefix = join_path(pkg, source_dir) if source_dir != None else "" @@ -212,6 +247,139 @@ def docs_bundle( **kwargs ) + return struct( + source_dir_globbed = source_dir_globbed, + sourcelinks = sourcelinks, + ) + +def docs_bundle( + name, + source_dir = None, + srcs = [], + data = [], + entry_doc = "index", + bundles = [], + scan_code = [], + code_targets = [], + visibility = None, + **kwargs): + """Declare a reusable documentation bundle.""" + bundle = _declare_docs_bundle( + name = name, + source_dir = source_dir, + srcs = srcs, + data = data, + entry_doc = entry_doc, + bundles = bundles, + scan_code = scan_code, + code_targets = code_targets, + visibility = visibility, + **kwargs + ) + source_dir_globbed = bundle.source_dir_globbed + sourcelinks = bundle.sourcelinks + + if source_dir_globbed or srcs: + # ``bundle_source_files`` is important here: using the complete bundle + # would also feed nested child sources into this Sphinx invocation and + # export their Needs under the parent's local target. Ownership stays + # one-way: every source-bearing bundle exports only its own sources. + own_sources = bundle_source_files( + name = _bundle_internal_target(name, "needs_sources"), + bundle = ":" + name, + visibility = visibility, + tags = ["manual"], + ) + + # Sphinx expects conf.py below the source root. Prefer a caller-provided + # config, and otherwise generate the same default config used by docs(). + config_file_path = join_path(source_dir, "conf.py") + if native.glob([config_file_path], allow_empty = True): + needs_config = ":" + config_file_path + else: + needs_config = ":" + _bundle_internal_target(name, "needs_conf") + _generated_conf( + name = _bundle_internal_target(name, "needs_conf"), + project = name, + project_url = "", + required_in_id = "", + entry_doc = entry_doc, + output_path = config_file_path, + tags = ["manual"], + ) + + # A Needs export also carries source-code-link metadata. Reuse the + # bundle's existing link file when there is one; create an empty file + # for the common no-code-target case; and merge multiple files when + # both deprecated scan_code and code_targets contributed inputs. + if len(sourcelinks) == 0: + needs_sourcelinks = ":" + _bundle_internal_target(name, "needs_sourcelinks_json") + _sourcelinks_json( + name = _bundle_internal_target(name, "needs_sourcelinks_json"), + srcs = [], + ) + elif len(sourcelinks) == 1: + needs_sourcelinks = sourcelinks[0] + else: + needs_sourcelinks_name = _bundle_internal_target(name, "needs_sourcelinks_json") + merge_bundle_sourcelinks( + name = needs_sourcelinks_name, + bundle = ":" + name, + visibility = visibility, + ) + needs_sourcelinks = ":" + needs_sourcelinks_name + + # This is the Sphinx executable used by the action below. The extension + # and PlantUML helper are explicit because a bundle-local export is a + # standalone Sphinx invocation, not the host docs() invocation. + needs_deps = all_requirements + [ + Label("//src:plantuml_for_python"), + Label("//src/extensions/score_sphinx_bundle:score_sphinx_bundle"), + ] + needs_sphinx_build = _bundle_internal_target(name, "needs_sphinx_build") + sphinx_build_binary( + name = needs_sphinx_build, + deps = needs_deps, + visibility = visibility, + tags = ["manual"], + ) + + source_strip_prefix = _bundle_sphinx_strip_prefix(source_dir) + + # The source files are declared with their workspace-relative paths, + # while sphinxdocs expects the prefix to remove from those paths before + # placing them below the temporary Sphinx source root. + # + # Build the own export from this bundle's sources only. References to + # Needs owned by another bundle are intentionally unsupported until + # cross-bundle imports are added. + needs_local = _bundle_internal_target(name, "needs_local") + sphinx_docs( + name = needs_local, + srcs = [own_sources], + config = needs_config, + # ``sphinxdocs`` removes this string literally from short_path. + # Keep the separator so a source_dir/conf.py is relocated as + # conf.py rather than /conf.py. + strip_prefix = source_strip_prefix, + extra_opts = [ + "-W", + "--keep-going", + "-T", + "--define=score_bundle_needs_export=1", + # Prevent the non-Bazel fallback query from importing the + # host project's external Needs into this standalone export. + "--define=external_needs_source=[]", + "--define=score_sourcelinks_json=$(location " + str(needs_sourcelinks) + ")", + ], + formats = ["needs"], + sphinx = ":" + needs_sphinx_build, + tools = [needs_sourcelinks], + visibility = visibility, + allow_persistent_workers = False, + tags = ["manual"], + ) + def _missing_requirements(deps): """Add Python hub dependencies if they are missing.""" found = [] @@ -381,7 +549,7 @@ def docs( # The public bundle carries both the complete source tree and the # transitive source-code links of every nested bundle. - docs_bundle( + _declare_docs_bundle( name = "docs_bundle", source_dir = source_dir, data = data, diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 9459c5c6f..642a27e6e 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -216,6 +216,14 @@ Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_do error. See :ref:`howto_mount_external_sources` for a worked example and :ref:`docs_concept_mounts` for the composition and transitivity semantics. +- ``needs_local`` (internal target) + A source-bearing bundle creates ``.__internal__.needs_local`` with the + Needs declared by its own sources. The standalone build is intentionally + self-contained in this version: references to Needs defined outside the + bundle remain unresolved and fail strict builds. Cross-bundle imports and + merged exports are planned for a later change. Data-only bundles do not + create a Needs target. + .. note:: A bundle is **placement-free**: its ``mount_at`` and ``attach_to`` are assigned diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 01c788293..44c038ade 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -243,6 +243,7 @@ def _clear_needs_defaults(app: Sphinx): def setup(app: Sphinx) -> dict[str, str | bool]: app.add_config_value("external_needs_source", "", rebuild="env") + app.add_config_value("score_bundle_needs_export", False, rebuild="env") app.add_config_value("score_metamodel_yaml", "", rebuild="env") app.add_config_value("required_in_id", [], rebuild="env") config_setdefault(app.config, "needs_id_required", True) diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index 51f927a21..22588ca44 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -130,30 +130,39 @@ def parse_external_needs_sources_from_bazel_query() -> list[ExternalNeedsSource] return res -def extend_needs_json_exporter(config: Config, params: list[str]) -> None: +def register_project_url_export( + config: Config, + *, + exported_project_url: str | None = None, +) -> None: + """Add ``project_url`` to the Needs JSON export. + + By default, the value comes from the Sphinx configuration and an empty + value is reported as a configuration error. ``exported_project_url`` is an + explicit replacement for the JSON value; supplying it also makes an empty + replacement valid without changing the active Sphinx configuration. """ - This will add each param to app.config as a config value. - Then it will overwrite the needs.json exporter to include these values. - """ - - for p in params: - # Note: we are currently addinig these values to config after config-inited. - # This is wrong. But good enough. - config.add(p, default="", rebuild="env", types=(), description="") + # ``project_url`` is a SCORE-specific configuration value, so register it + # before accessing it. This currently happens during ``config-inited``; + # moving the declaration into extension setup is a separate cleanup. + config.add("project_url", default="", rebuild="env", types=(), description="") + configured_project_url = config.project_url + if exported_project_url is None and not configured_project_url: + logger.error( + "Config value 'project_url' is not set. " + + "Please set it in your Sphinx config." + ) - if not getattr(config, p): - logger.error( - f"Config value '{p}' is not set. " - + "Please set it in your Sphinx config." - ) + project_url = ( + configured_project_url if exported_project_url is None else exported_project_url + ) - # Patch json exporter to include our custom fields - # Note: yeah, NeedsList is the json exporter! + # Patch json exporter to include our custom field. + # Note: ``NeedsList`` is the sphinx-needs JSON exporter. orig_function = NeedsList._finalise # pyright: ignore[reportPrivateUsage] def temp(self: NeedsList): - for p in params: - self.needs_list[p] = getattr(config, p) # pyright: ignore[reportUnknownMemberType] + self.needs_list["project_url"] = project_url orig_function(self) @@ -228,7 +237,12 @@ def add_external_docs_sources(e: ExternalNeedsSource, config: Config): def connect_external_needs(app: Sphinx, config: Config): - extend_needs_json_exporter(config, ["project_url"]) + # Bundle-local exports intentionally leave project_url empty, while host + # builds retain the existing missing-value diagnostic. + register_project_url_export( + config, + exported_project_url="" if config.score_bundle_needs_export else None, + ) # Local external needs from DATA (e.g. :needs_json or :docs_sources) external_needs = get_external_needs_source(app.config.external_needs_source) diff --git a/src/extensions/score_metamodel/tests/test_external_needs.py b/src/extensions/score_metamodel/tests/test_external_needs.py index 5c8929b41..59ec1d426 100644 --- a/src/extensions/score_metamodel/tests/test_external_needs.py +++ b/src/extensions/score_metamodel/tests/test_external_needs.py @@ -19,6 +19,8 @@ import json from pathlib import Path +from types import SimpleNamespace +from typing import cast import pytest import score_metamodel.external_needs as ext_needs @@ -30,7 +32,91 @@ get_external_needs_source, parse_external_needs_sources_from_DATA, ) +from sphinx.application import Sphinx from sphinx.config import Config +from sphinx_needs.needsfile import NeedsList + + +def test_register_project_url_export_reports_missing_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The host export reports a missing project URL as a configuration error.""" + errors: list[str] = [] + monkeypatch.setattr(NeedsList, "_finalise", lambda _needs_list: None) + monkeypatch.setattr(ext_needs.logger, "error", errors.append) + + ext_needs.register_project_url_export(Config()) + + assert errors == [ + "Config value 'project_url' is not set. Please set it in your Sphinx config." + ] + + +def test_register_project_url_export_uses_configured_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The host export writes the configured project URL to the JSON document.""" + config = Config() + config.project_url = "https://example.test/host" + + monkeypatch.setattr(NeedsList, "_finalise", lambda _needs_list: None) + ext_needs.register_project_url_export(config) + + needs_list = cast(NeedsList, SimpleNamespace(needs_list={})) + NeedsList._finalise(needs_list) # pyright: ignore[reportPrivateUsage] - white-box test + + assert needs_list.needs_list["project_url"] == "https://example.test/host" + + +def test_register_project_url_export_can_override_exported_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An export override does not mutate the active Sphinx configuration.""" + config = Config() + config.project_url = "https://example.test/host" + + def no_op_finalise(_needs_list: NeedsList) -> None: + pass + + monkeypatch.setattr(NeedsList, "_finalise", no_op_finalise) + ext_needs.register_project_url_export(config, exported_project_url="") + + needs_list = cast(NeedsList, SimpleNamespace(needs_list={})) + NeedsList._finalise(needs_list) # pyright: ignore[reportPrivateUsage] - white-box test + + assert needs_list.needs_list["project_url"] == "" + assert config.project_url == "https://example.test/host" + + +@pytest.mark.parametrize( + ("bundle_needs_export", "exported_project_url"), + [(False, None), (True, "")], +) +def test_connect_external_needs_selects_project_url_export_mode( + monkeypatch: pytest.MonkeyPatch, + bundle_needs_export: bool, + exported_project_url: str | None, +) -> None: + """Only bundle-local exports replace the project URL in the JSON output.""" + observed: list[str | None] = [] + + def record_exporter_call( + _config: Config, + *, + exported_project_url: str | None, + ) -> None: + observed.append(exported_project_url) + + monkeypatch.setattr(ext_needs, "register_project_url_export", record_exporter_call) + + config = Config() + config.external_needs_source = "[]" + config.score_bundle_needs_export = bundle_needs_export + ext_needs.connect_external_needs( + cast(Sphinx, SimpleNamespace(config=config)), config + ) + + assert observed == [exported_project_url] def test_empty_list(): diff --git a/src/tests/docs_bzl/README.md b/src/tests/docs_bzl/README.md index 78a846b18..21ae04e6b 100644 --- a/src/tests/docs_bzl/README.md +++ b/src/tests/docs_bzl/README.md @@ -23,7 +23,8 @@ docs_bzl/ │ ├── subdirectory_bundle/ │ ├── external_bundle/ │ ├── local_version_mismatch/ -│ └── invalid_bundle_placements/ +│ ├── invalid_bundle_placements/ +│ └── upward_bundles/ └── test_.py ``` @@ -36,6 +37,12 @@ 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. +The ``upward_bundles`` scenario is a rough specification for cross-bundle Needs +propagation: local exports remain limited to a bundle's own sources, while a +separate upward export merges explicitly declared direct ancestors. It also +covers source-less ancestors and the rule that undeclared transitive ancestors +are not inherited. + 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 Bazel server and its action, repository, and disk caches are reused between diff --git a/src/tests/docs_bzl/scenarios/local_bundle/BUILD b/src/tests/docs_bzl/scenarios/local_bundle/BUILD new file mode 100644 index 000000000..eacb95077 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_bundle/BUILD @@ -0,0 +1,22 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:docs.bzl", "docs_bundle") + +# A source-bearing bundle exports the Needs declared by its own documentation +# through the internal ``needs_local`` target. This fixture deliberately has no +# cross-bundle references; those are handled by a later hierarchy change. +docs_bundle( + name = "platform", + source_dir = "platform", +) diff --git a/src/tests/docs_bzl/scenarios/local_bundle/platform/conf.py b/src/tests/docs_bzl/scenarios/local_bundle/platform/conf.py new file mode 100644 index 000000000..eaedf413a --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_bundle/platform/conf.py @@ -0,0 +1,17 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +project = "Platform bundle" +version = "main" +extensions = ["score_sphinx_bundle"] +required_in_id = ["platform"] diff --git a/src/tests/docs_bzl/scenarios/local_bundle/platform/index.rst b/src/tests/docs_bzl/scenarios/local_bundle/platform/index.rst new file mode 100644 index 000000000..aeae38a58 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/local_bundle/platform/index.rst @@ -0,0 +1,35 @@ +.. + # ******************************************************************************* + # 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 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Platform +======== + +.. feat:: Platform seat heating + :id: feat__platform_seat_heating + :version: 1 + :security: NO + :safety: QM + :status: valid + +.. feat_req:: Platform seat heating availability + :id: feat_req__platform__seat_heating + :version: 1 + :reqtype: Functional + :security: NO + :safety: QM + :status: valid + :valid_from: v1.0 + :satisfied_by: feat__platform_seat_heating + + The platform makes the seat heating capability available. diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/BUILD b/src/tests/docs_bzl/scenarios/upward_bundles/BUILD new file mode 100644 index 000000000..9ad3139e3 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/BUILD @@ -0,0 +1,42 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("//:docs.bzl", "docs_bundle") + +# This scenario specifies the intended split between a bundle's own Needs and +# the Needs inherited from explicitly declared direct ancestors. It is a rough +# draft for the later propagation implementation. +docs_bundle( + name = "platform", + source_dir = "platform", +) + +docs_bundle( + name = "component", + source_dir = "component", + # Expected future API: make the platform export available as direct + # resolution context and merge it into component's upward export. + upward_bundles = ["platform"], +) + +docs_bundle( + name = "application", + # Declaring only component must not implicitly include platform. + upward_bundles = ["component"], +) + +# A source-less hierarchy group must not be accepted as a direct Needs ancestor. +docs_bundle( + name = "invalid_child", + upward_bundles = ["application"], +) diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/component/conf.py b/src/tests/docs_bzl/scenarios/upward_bundles/component/conf.py new file mode 100644 index 000000000..918b8540e --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/component/conf.py @@ -0,0 +1,17 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +project = "Component bundle" +version = "main" +extensions = ["score_sphinx_bundle"] +required_in_id = ["component"] diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst b/src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst new file mode 100644 index 000000000..3c1666f7e --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/component/index.rst @@ -0,0 +1,36 @@ +.. + # ******************************************************************************* + # 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 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Component +========= + +.. comp:: Seat heating controller + :id: comp__component_seat_heating_controller + :version: 1 + :security: NO + :safety: QM + :status: valid + :belongs_to: feat__platform_seat_heating + +.. comp_req:: Controller temperature control + :id: comp_req__component__temperature_control + :version: 1 + :reqtype: Functional + :security: NO + :safety: QM + :status: valid + :derived_from: feat_req__platform__seat_heating + :satisfied_by: comp__component_seat_heating_controller + + The controller regulates the requested heating level. diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/platform/conf.py b/src/tests/docs_bzl/scenarios/upward_bundles/platform/conf.py new file mode 100644 index 000000000..eaedf413a --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/platform/conf.py @@ -0,0 +1,17 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +project = "Platform bundle" +version = "main" +extensions = ["score_sphinx_bundle"] +required_in_id = ["platform"] diff --git a/src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst b/src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst new file mode 100644 index 000000000..aeae38a58 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/upward_bundles/platform/index.rst @@ -0,0 +1,35 @@ +.. + # ******************************************************************************* + # 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 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +Platform +======== + +.. feat:: Platform seat heating + :id: feat__platform_seat_heating + :version: 1 + :security: NO + :safety: QM + :status: valid + +.. feat_req:: Platform seat heating availability + :id: feat_req__platform__seat_heating + :version: 1 + :reqtype: Functional + :security: NO + :safety: QM + :status: valid + :valid_from: v1.0 + :satisfied_by: feat__platform_seat_heating + + The platform makes the seat heating capability available. diff --git a/src/tests/docs_bzl/test_local_bundle.py b/src/tests/docs_bzl/test_local_bundle.py new file mode 100644 index 000000000..a78dc9557 --- /dev/null +++ b/src/tests/docs_bzl/test_local_bundle.py @@ -0,0 +1,32 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Focused tests for source-bearing docs_bundle Needs exports.""" + +from src.tests.docs_bzl.helpers import built_output, load_needs, run_scenario + + +def test_source_bundle_exports_its_own_needs(): + """A local export contains the Needs declared by the bundle's own sources.""" + run_scenario("build", "local_bundle", ":platform.__internal__.needs_local") + + needs = load_needs( + built_output( + "scenarios/local_bundle", + "platform.__internal__.needs_local/_build/needs/needs.json", + ) + ) + + assert { + "feat__platform_seat_heating", + "feat_req__platform__seat_heating", + } <= needs.keys() diff --git a/src/tests/docs_bzl/test_upward_bundles.py b/src/tests/docs_bzl/test_upward_bundles.py new file mode 100644 index 000000000..92403d23f --- /dev/null +++ b/src/tests/docs_bzl/test_upward_bundles.py @@ -0,0 +1,86 @@ +# ******************************************************************************* +# 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 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Rough specification tests for cross-bundle Needs propagation.""" + +from src.tests.docs_bzl.helpers import built_output, load_needs, run_bazel, run_scenario + + +def test_source_bundle_exports_own_needs_and_declared_parent_needs(): + """A local export stays local while an upward export merges direct parents.""" + run_scenario("build", "upward_bundles", ":component.__internal__.needs_local") + run_scenario("build", "upward_bundles", ":component.__internal__.needs_upward") + + local_needs = load_needs( + built_output( + "scenarios/upward_bundles", + "component.__internal__.needs_local/_build/needs/needs.json", + ) + ) + needs = load_needs( + built_output( + "scenarios/upward_bundles", + "component.__internal__.needs_upward/needs.json", + ) + ) + + assert { + "comp__component_seat_heating_controller", + "comp_req__component__temperature_control", + } <= local_needs.keys() + assert "feat_req__platform__seat_heating" not in local_needs + + assert { + "feat__platform_seat_heating", + "feat_req__platform__seat_heating", + "comp__component_seat_heating_controller", + "comp_req__component__temperature_control", + } <= needs.keys() + + +def test_source_less_bundle_does_not_inherit_undeclared_ancestor_needs(): + """A chain only includes the explicitly declared direct ancestor.""" + run_scenario("build", "upward_bundles", ":application.__internal__.needs_upward") + + needs = load_needs( + built_output( + "scenarios/upward_bundles", + "application.__internal__.needs_upward/needs.json", + ) + ) + + assert "comp__component_seat_heating_controller" in needs + assert "feat__platform_seat_heating" not in needs + + +def test_source_less_bundle_is_rejected_as_upward_ancestor(): + """A source-less hierarchy group cannot provide a direct local export.""" + result = run_scenario( + "build", + "upward_bundles", + ":invalid_child.__internal__.needs_upward", + expect_error=True, + ) + + assert "has no own documentation sources" in result.stderr + assert "would introduce transitive dependencies" in result.stderr + + +def test_data_only_bundle_does_not_get_needs_targets(): + """Supporting-data bundles remain outside the Needs export hierarchy.""" + run_bazel( + [ + "query", + "//src/tests/docs_bzl/scenarios/data_files_runfiles:legacy_data_bundle.__internal__.needs_local", + ], + expect_error=True, + )