From 64d179321604de5febe542744021dd0c6166e356 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 23 Sep 2026 00:36:21 +0200 Subject: [PATCH 1/7] feat: attach bundle targets to Needs --- src/extensions/score_metamodel/BUILD | 1 + src/extensions/score_metamodel/__init__.py | 4 + .../score_metamodel/bundle_matching.py | 99 +++++++ .../score_metamodel/bundle_metadata.py | 251 ++++++++++++++++++ src/extensions/score_metamodel/metamodel.yaml | 2 + .../tests/test_bundle_lifecycle.py | 207 +++++++++++++++ .../tests/test_bundle_matching.py | 94 +++++++ .../reference_integration/legacy_module/BUILD | 2 +- .../docs/components/component/BUILD | 2 +- .../reference_integration/modern_module/BUILD | 2 +- .../docs/components/component/BUILD | 2 +- .../docs_bzl/test_reference_integration.py | 29 +- 12 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 src/extensions/score_metamodel/bundle_matching.py create mode 100644 src/extensions/score_metamodel/bundle_metadata.py create mode 100644 src/extensions/score_metamodel/tests/test_bundle_lifecycle.py create mode 100644 src/extensions/score_metamodel/tests/test_bundle_matching.py diff --git a/src/extensions/score_metamodel/BUILD b/src/extensions/score_metamodel/BUILD index be9c61605..5d862c620 100644 --- a/src/extensions/score_metamodel/BUILD +++ b/src/extensions/score_metamodel/BUILD @@ -57,6 +57,7 @@ py_library( # 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_mounts", "@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 41847944f..0fc6917ae 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -21,6 +21,7 @@ from sphinx_needs.data import NeedsView, SphinxNeedsData from sphinx_needs.need_item import NeedItem +from src.extensions.score_metamodel.bundle_metadata import apply_bundle_metadata from src.extensions.score_metamodel.external_needs import connect_external_needs from src.extensions.score_metamodel.log import CheckLogger @@ -300,6 +301,9 @@ def setup(app: Sphinx) -> dict[str, str | bool]: ) _ = app.connect("write-started", lambda app, _builder: _run_checks(app)) + # The template extension may purge and reread report pages at priority 600. + # Matching after that pass makes the final Need collection authoritative. + _ = app.connect("env-updated", apply_bundle_metadata, priority=650) return { "version": "0.1", diff --git a/src/extensions/score_metamodel/bundle_matching.py b/src/extensions/score_metamodel/bundle_matching.py new file mode 100644 index 000000000..31568e415 --- /dev/null +++ b/src/extensions/score_metamodel/bundle_matching.py @@ -0,0 +1,99 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Find the local Sphinx-Needs item belonging to a documentation bundle.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass + +from sphinx_needs.need_item import NeedItem + + +@dataclass(frozen=True) +class BundleNeedMatch: + """Result of matching one bundle against its local Needs. + + This result is created during the ``env-updated`` pass, after Sphinx has + collected the current Needs. Keeping either the unambiguous Need ID or a + diagnostic in one object lets the caller decide whether it is safe to + write bundle metadata without guessing on an ambiguous match. + + Exactly one of ``need_id`` and ``error`` is populated. The error is already + formatted for the diagnostic emitted by the caller. + """ + + need_id: str | None = None + error: str | None = None + + def __post_init__(self) -> None: + """Reject a result that is neither a match nor a diagnostic.""" + if bool(self.need_id) == bool(self.error): + raise ValueError("exactly one of need_id and error must be non-empty") + + +def match_bundle_to_need( + bundle_name: str, + own_needs: Iterable[NeedItem | Mapping[str, object]], +) -> BundleNeedMatch: + """Find the Need whose ID follows ``__``. + + The bundle-metadata callback calls this once per current bundle after it + has grouped the Needs declared in that bundle's documents. The exact ID + and unique-type checks are necessary because a bundle name alone is not a + safe global identifier; when they fail, the caller must leave the Need + unchanged rather than attach metadata to an arbitrary Need. + + The caller supplies only the Needs declared in the bundle's own documents. + The function deliberately does not guess when no Need matches, when more + than one Need type uses the same bundle name, or when the type is not + unique within the bundle. + """ + needs = list(own_needs) + + # A Need belongs to this bundle only when its ID combines its type with + # the bundle name, for example ``comp__memory`` for ``memory``. + candidates = sorted( + (need for need in needs if need["id"] == f"{need['type']}__{bundle_name}"), + key=lambda need: str(need["id"]), + ) + candidate_ids = tuple(str(need["id"]) for need in candidates) + if not candidates: + return BundleNeedMatch( + error=f"no Need has the exact ID suffix for {bundle_name!r}" + ) + + # Never choose between multiple Needs that claim the same bundle name. + if len(candidates) != 1: + return BundleNeedMatch( + error="multiple Needs have the exact bundle name: " + + ", ".join(candidate_ids) + ) + + candidate = candidates[0] + candidate_type = str(candidate["type"]) + + # A single name match is safe only when that Need type is unique in the + # bundle; otherwise another Need of the same type could be the intended one. + same_type = sorted( + (need for need in needs if need.get("type") == candidate_type), + key=lambda need: str(need["id"]), + ) + conflicting_ids = tuple(str(need["id"]) for need in same_type) + if len(same_type) != 1: + return BundleNeedMatch( + error="Need type is not unique in the bundle: " + ", ".join(conflicting_ids) + ) + + # The name and type checks above leave exactly one unambiguous Need. + return BundleNeedMatch(need_id=str(candidate["id"])) diff --git a/src/extensions/score_metamodel/bundle_metadata.py b/src/extensions/score_metamodel/bundle_metadata.py new file mode 100644 index 000000000..6b37ab2ac --- /dev/null +++ b/src/extensions/score_metamodel/bundle_metadata.py @@ -0,0 +1,251 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Copy Bazel target information from documentation bundles to local Needs. + +Each documentation bundle can own one or more Sphinx documents and can expose +the Bazel targets that produced its source files. This module finds the Needs +declared in those documents, adds the target information to the matching Need, +and removes the values from local Needs that no longer have a matching bundle. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from dataclasses import dataclass + +from sphinx.application import Sphinx +from sphinx_needs import logging +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.need_item import NeedItem + +from src.extensions.score_metamodel.bundle_matching import match_bundle_to_need +from src.extensions.score_mounts import get_document_bundles +from src.extensions.score_mounts._resolver import BundleMetadata + +logger = logging.get_logger(__name__) + + +@dataclass +class _BundleMetadataUpdate: + """Results of applying bundle metadata to the current Needs. + + This object exists only for one ``env-updated`` pass. Matching must finish + before cleanup can run, so the cleanup step needs both the complete set of + successful matches and the documents changed while applying them. + + ``matched_need_ids`` identifies Needs whose bundle still matches, and + ``changed_docnames`` contains documents whose Need values were changed. + The latter is returned to Sphinx so those documents can be considered + changed by the build. + """ + + matched_need_ids: set[str] + changed_docnames: set[str] + + +def _render_target_values(bundle: BundleMetadata) -> dict[str, str]: + """Convert a bundle's Bazel targets to values stored on a Need. + + This is called for an unambiguous bundle match during ``env-updated``. + Bundle metadata is the authoritative source for these extension-owned + fields, so the current targets must be rendered before they are written to + the Need. A single target keeps the existing scalar representation; + multiple targets use compact JSON because the Need fields are strings. + + A single target is stored as a plain label and rule type. Multiple targets + are stored as compact JSON lists, keeping labels and types in the same + declared order. + """ + targets = bundle.code_targets + labels = [target.label for target in targets] + types = [target.type for target in targets] + if len(targets) == 1: + return {"bazel_target": labels[0], "bazel_type": types[0]} + return { + "bazel_target": json.dumps(labels, separators=(",", ":")), + "bazel_type": json.dumps(types, separators=(",", ":")), + } + + +def _clear_bundle_values(need: NeedItem) -> bool: + """Clear extension-owned values from a Need with no current match. + + This is used during the final cleanup step of ``env-updated``. Sphinx + keeps Needs from unchanged documents in its environment, so a value that + was correct in an earlier build can remain after a bundle is renamed, + removed, or can no longer be matched. The return value tells the caller + whether the owning document must be reported as changed. + """ + changed = False + for field in ("bazel_target", "bazel_type"): + if need.get(field): + need[field] = "" + changed = True + return changed + + +def _update_bundle_values( + need: NeedItem, + values: dict[str, str], +) -> bool: + """Apply current bundle values and report whether the Need was changed. + + This is called for every unambiguous match during ``env-updated``. The + bundle is authoritative for these generated fields, so current values are + written even when an older value is already present. The boolean is + needed only to avoid asking Sphinx to rebuild a document whose Need values + are already current. + """ + changed = False + for field in ("bazel_target", "bazel_type"): + value = values[field] + raw_current = need.get(field, "") + current = "" if raw_current is None else str(raw_current) + if current == value: + continue + need[field] = value + changed = True + return changed + + +def _group_bundle_needs( + owners: dict[str, BundleMetadata], + needs: dict[str, NeedItem], +) -> tuple[dict[str, BundleMetadata], dict[str, list[NeedItem]]]: + """Collect each bundle's own Needs before matching by bundle name. + + This runs at the start of ``env-updated`` from the current Sphinx + environment. Matching needs this grouping because the same Need ID shape + can occur in different documents, while a bundle must only receive + metadata for Needs declared in its own documents. + + Imported and external Needs are excluded: a bundle must only receive + metadata for Needs declared in its own documents, not for requirements + copied in from another documentation bundle. + """ + grouped: dict[str, list[NeedItem]] = defaultdict(list) + bundle_by_label: dict[str, BundleMetadata] = {} + for owner in owners.values(): + if owner.label and owner.code_targets: + bundle_by_label.setdefault(owner.label, owner) + + for need in needs.values(): + if need.get("is_external") or need.get("is_import"): + continue + docname = need.get("docname") + if not isinstance(docname, str): + continue + owner = owners.get(docname) + if owner is None or not owner.label or not owner.code_targets: + continue + grouped[owner.label].append(need) + return bundle_by_label, grouped + + +def _apply_matching_bundles( + bundles: dict[str, BundleMetadata], + grouped: dict[str, list[NeedItem]], +) -> _BundleMetadataUpdate: + """Add each bundle's target information to its uniquely matching Need. + + This runs after ``_group_bundle_needs`` and before cleanup in the same + ``env-updated`` pass. It records only successful matches so cleanup can + distinguish a Need that still has a valid bundle from one carrying stale + metadata. It also records changed documents because Sphinx tracks rebuilds + by document, not by individual Need. + """ + matched_need_ids: set[str] = set() + changed_docnames: set[str] = set() + for bundle_label, bundle in sorted(bundles.items()): + result = match_bundle_to_need(bundle.name, grouped.get(bundle_label, [])) + if result.error: + logger.info( + f"bundle {bundle.label!r} ({bundle.name!r}) has direct code targets " + f"but cannot be associated with one local Need: {result.error}", + type="score_metamodel", + ) + continue + + matching_need = next( + need for need in grouped[bundle_label] if need["id"] == result.need_id + ) + need_id = str(matching_need["id"]) + changed = _update_bundle_values( + matching_need, + _render_target_values(bundle), + ) + matched_need_ids.add(need_id) + if changed: + # Sphinx tracks changes by document, while the metadata is stored + # on individual Needs. Rebuild the owning document when one of its + # Need values changes. + docname = matching_need.get("docname") + if isinstance(docname, str): + changed_docnames.add(docname) + + return _BundleMetadataUpdate( + matched_need_ids=matched_need_ids, + changed_docnames=changed_docnames, + ) + + +def _clear_unmatched_bundle_metadata( + needs: dict[str, NeedItem], + matched_need_ids: set[str], +) -> set[str]: + """Remove stale bundle metadata after all current bundles were matched. + + This must run once, after ``_apply_matching_bundles`` has seen every + bundle; running it earlier could clear a Need before a later bundle has a + chance to match it. It is necessary because Sphinx reuses Needs from + unchanged documents, so removed or renamed bundles otherwise leave their + old generated Bazel values behind. The returned document names tell + Sphinx to include documents affected by that cleanup in the rebuild. + """ + changed_docnames: set[str] = set() + for need_id, current_need in needs.items(): + if need_id in matched_need_ids: + continue + if current_need.get("is_external") or current_need.get("is_import"): + continue + if _clear_bundle_values(current_need): + # Clearing bundle values changes the Need in this document, so it + # belongs in the same document-change set as updating a match. + docname = current_need.get("docname") + if isinstance(docname, str): + changed_docnames.add(docname) + return changed_docnames + + +def apply_bundle_metadata(app: Sphinx, _: object) -> list[str]: + """Update local Needs after Sphinx has collected the current documents. + + This is the ``env-updated`` callback and runs after collection and other + extensions have finished updating the environment. It gets the current + bundle ownership map, matches bundles to their own Needs, updates the + generated Bazel fields, then removes stale values from unmatched local + Needs. It returns the affected documents because Sphinx uses callback + return values to decide which documents need to be written again. + """ + owners = get_document_bundles(app) + needs_data = SphinxNeedsData(app.env) + needs = needs_data.get_needs_mutable() + bundles, grouped = _group_bundle_needs(owners, needs) + update = _apply_matching_bundles(bundles, grouped) + # A document is changed both when new metadata is written and when stale + # metadata is removed because its bundle no longer matches. + update.changed_docnames.update( + _clear_unmatched_bundle_metadata(needs, update.matched_need_ids) + ) + return sorted(update.changed_docnames) diff --git a/src/extensions/score_metamodel/metamodel.yaml b/src/extensions/score_metamodel/metamodel.yaml index acbced5cc..9a3194414 100644 --- a/src/extensions/score_metamodel/metamodel.yaml +++ b/src/extensions/score_metamodel/metamodel.yaml @@ -17,6 +17,8 @@ needs_types_base_options: # req-Id: tool_req__docs_dd_link_source_code_link source_code_link: ^https://github.com/.* testlink: ^https://github.com/.* + bazel_target: ^.*$ + bazel_type: ^.*$ # Version will be mandatory global option in future releases # For now giving grace periods to consumers mandatory_options: diff --git a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py new file mode 100644 index 000000000..ee33cdabc --- /dev/null +++ b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py @@ -0,0 +1,207 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +from types import SimpleNamespace +from typing import cast + +import pytest +from sphinx.application import Sphinx +from sphinx_needs.data import NeedsInfoType +from sphinx_needs.need_item import NeedItem, NeedItemSourceUnknown, NeedsContent + +import src.extensions.score_metamodel.bundle_metadata as bundle_metadata +from src.extensions.score_mounts._resolver import BazelTarget, BundleMetadata + + +def _need( + *, + bazel_target: str = "", + bazel_type: str = "", +) -> NeedItem: + """Create a local component Need named ``memory`` for the test bundle.""" + core = cast( + NeedsInfoType, + { + "id": "comp__memory", + "type": "comp", + "title": "Memory", + "status": "open", + "tags": [], + "collapse": False, + "hide": False, + "layout": None, + "style": None, + "external_css": "", + "type_name": "Component", + "type_prefix": "comp__", + "type_color": "", + "type_style": "", + "constraints": [], + "arch": {}, + "sections": (), + "signature": None, + "has_dead_links": False, + "has_forbidden_dead_links": False, + }, + ) + return NeedItem( + source=NeedItemSourceUnknown(docname="index"), + content=NeedsContent(doctype="rst", content="The original content."), + core=core, + extras={ + "bazel_target": bazel_target, + "bazel_type": bazel_type, + }, + links={}, + _validate=False, + ) + + +def _bundle() -> BundleMetadata: + """Create a bundle named ``memory`` with one direct Bazel target.""" + return BundleMetadata( + label="//:memory", + name="memory", + code_targets=(BazelTarget(label="//:memory_core", type="cc_library"),), + ) + + +def test_matching_updates_need_in_place_and_returns_affected_document( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The ``memory`` bundle adds its target to the local ``comp__memory`` Need.""" + need = _need() + original_content = need.content + + class FakeNeedsData: + def __init__(self, _env: object) -> None: + self.needs = {"comp__memory": need} + + def get_needs_mutable(self): + return self.needs + + def remove_need(self, _need_id: str) -> None: + raise AssertionError("matching must not remove the Need") + + def add_need(self, _need: NeedItem) -> None: + raise AssertionError("matching must not replace the Need") + + monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) + monkeypatch.setattr( + bundle_metadata, + "get_document_bundles", + lambda _app: {"index": _bundle()}, + ) + app = SimpleNamespace(env=SimpleNamespace(), srcdir=".") + + changed = bundle_metadata.apply_bundle_metadata(cast(Sphinx, app), None) + + assert changed == ["index"] + assert need.content is original_content + assert need["bazel_target"] == "//:memory_core" + assert need["bazel_type"] == "cc_library" + + +def test_unchanged_bundle_metadata_does_not_rewrite_the_document( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reapplying unchanged bundle metadata does not rewrite the document.""" + need = _need( + bazel_target="//:memory_core", + bazel_type="cc_library", + ) + + class FakeNeedsData: + def __init__(self, _env: object) -> None: + self.needs = {"comp__memory": need} + + def get_needs_mutable(self): + return self.needs + + monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) + monkeypatch.setattr( + bundle_metadata, + "get_document_bundles", + lambda _app: {"index": _bundle()}, + ) + app = SimpleNamespace(env=SimpleNamespace(), srcdir=".") + + changed = bundle_metadata.apply_bundle_metadata(cast(Sphinx, app), None) + + assert changed == [] + assert need["bazel_target"] == "//:memory_core" + assert need["bazel_type"] == "cc_library" + + +def test_bundle_metadata_is_cleared_when_matching_is_lost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A renamed bundle no longer matches and loses its old metadata.""" + need = _need( + bazel_target="//:memory_core", + bazel_type="cc_library", + ) + + class FakeNeedsData: + def __init__(self, _env: object) -> None: + self.needs = {"comp__memory": need} + + def get_needs_mutable(self): + return self.needs + + unmatched_bundle = BundleMetadata( + label="//:memory", + name="other", + code_targets=_bundle().code_targets, + ) + monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) + monkeypatch.setattr( + bundle_metadata, + "get_document_bundles", + lambda _app: {"index": unmatched_bundle}, + ) + app = SimpleNamespace(env=SimpleNamespace(), srcdir=".") + + changed = bundle_metadata.apply_bundle_metadata(cast(Sphinx, app), None) + + assert changed == ["index"] + assert need["bazel_target"] == "" + assert need["bazel_type"] == "" + + +def test_current_bundle_metadata_replaces_old_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The current bundle metadata is authoritative for the matching Need.""" + need = _need(bazel_target="//:old", bazel_type="old_type") + + class FakeNeedsData: + def __init__(self, _env: object) -> None: + self.needs = {"comp__memory": need} + + def get_needs_mutable(self): + return self.needs + + monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) + monkeypatch.setattr( + bundle_metadata, + "get_document_bundles", + lambda _app: {"index": _bundle()}, + ) + app = SimpleNamespace(env=SimpleNamespace(), srcdir=".") + + changed = bundle_metadata.apply_bundle_metadata(cast(Sphinx, app), None) + + assert changed == ["index"] + assert need["bazel_target"] == "//:memory_core" + assert need["bazel_type"] == "cc_library" diff --git a/src/extensions/score_metamodel/tests/test_bundle_matching.py b/src/extensions/score_metamodel/tests/test_bundle_matching.py new file mode 100644 index 000000000..882e3942b --- /dev/null +++ b/src/extensions/score_metamodel/tests/test_bundle_matching.py @@ -0,0 +1,94 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +from src.extensions.score_metamodel.bundle_matching import match_bundle_to_need + + +def need(*, need_id: str, need_type: str) -> dict[str, str]: + """Represent the matching fields of one local Sphinx-Needs item.""" + return {"id": need_id, "type": need_type} + + +def test_matches_the_exact_name_and_unique_type() -> None: + """The ``memory`` bundle matches the only local Need named ``memory``.""" + bundle_name = "memory" + own_needs = [ + # This Need has the expected ``__`` ID. + need(need_id="comp__memory", need_type="comp"), + # This Need belongs to another bundle and must not affect the match. + need(need_id="feat__storage", need_type="feat"), + ] + + result = match_bundle_to_need(bundle_name, own_needs) + + assert result.need_id == "comp__memory" + + +def test_does_not_match_case_insensitively() -> None: + """A bundle name must match the Need ID with the same casing.""" + bundle_name = "memory" + own_needs = [ + # ``comp__Memory`` is not the exact ID for the ``memory`` bundle. + need(need_id="comp__Memory", need_type="comp"), + ] + + result = match_bundle_to_need(bundle_name, own_needs) + + assert result.error == "no Need has the exact ID suffix for 'memory'" + + +def test_rejects_multiple_matching_names_across_types() -> None: + """The matcher refuses when two Need types claim the same bundle.""" + bundle_name = "memory" + own_needs = [ + need(need_id="comp__memory", need_type="comp"), + need(need_id="feat__memory", need_type="feat"), + ] + + result = match_bundle_to_need(bundle_name, own_needs) + + # Both IDs match the bundle name, so choosing one type would be arbitrary. + assert ( + result.error + == "multiple Needs have the exact bundle name: comp__memory, feat__memory" + ) + + +def test_rejects_a_second_need_of_the_matching_type() -> None: + """The matching Need type must be unique within the bundle.""" + bundle_name = "memory" + own_needs = [ + need(need_id="comp__memory", need_type="comp"), + need(need_id="comp__other", need_type="comp"), + ] + + result = match_bundle_to_need(bundle_name, own_needs) + + # The name identifies ``comp__memory``, but the ``comp`` type is not unique. + assert ( + result.error + == "Need type is not unique in the bundle: comp__memory, comp__other" + ) + + +def test_does_not_use_an_arbitrary_need_as_fallback() -> None: + """A different Need name is not used as a fallback match.""" + bundle_name = "memory" + own_needs = [ + # There is a component Need, but it belongs to ``other``, not ``memory``. + need(need_id="comp__other", need_type="comp"), + ] + + result = match_bundle_to_need(bundle_name, own_needs) + + assert result.error == "no Need has the exact ID suffix for 'memory'" diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD index 92a53a08c..2e8746064 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD @@ -36,7 +36,7 @@ docs( "@score_process_description//:needs_json", ], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:docs_bundle", + "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:legacy_component", "mount_at": "components/component", "attach_to": "components", }], diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD index e26a1dfdf..13cf64e03 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD @@ -23,7 +23,7 @@ filegroup( # ``code_targets`` so its traceability annotation exercises the source-link # generation path used by real component bundles. docs_bundle( - name = "docs_bundle", + name = "legacy_component", source_dir = ".", code_targets = [":component_sources"], visibility = ["//visibility:public"], diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD index 43da4e4b9..a21b991fb 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD @@ -29,7 +29,7 @@ docs( "@score_process_description//:needs_json", ], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component:docs_bundle", + "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component:modern_component", "mount_at": "components/component", "attach_to": "components", }, { diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD index e3d2d92cd..f6c96be2c 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD @@ -28,7 +28,7 @@ filegroup( # requirement links to the platform feature, so the component Needs target is # valid only when the parent module supplies that external Needs. docs_bundle( - name = "docs_bundle", + name = "modern_component", source_dir = ".", code_targets = [":component_sources"], visibility = ["//visibility:public"], diff --git a/src/tests/docs_bzl/test_reference_integration.py b/src/tests/docs_bzl/test_reference_integration.py index 87438baa7..0edc99726 100644 --- a/src/tests/docs_bzl/test_reference_integration.py +++ b/src/tests/docs_bzl/test_reference_integration.py @@ -15,7 +15,7 @@ import pytest -from src.tests.docs_bzl.helpers import run_scenario +from src.tests.docs_bzl.helpers import load_needs, run_scenario @pytest.mark.bazel_slow @@ -41,7 +41,7 @@ def test_linked_component_requires_parent_context(): run_scenario( "build", "reference_integration/modern_module/docs/components/component", - ":docs_bundle.__internal__.needs_local", + ":modern_component.__internal__.needs_local", ) assert "feat_req__platform__feature" in str(exc_info.value) @@ -101,3 +101,28 @@ def test_reference_integration_builds_with_platform_requirements(): / "component" / "index.html" ).is_file() + + +@pytest.mark.bazel_cached +def test_bundle_metadata_is_added_to_the_matching_need(): + """A component bundle contributes its direct Bazel target to its Need. + + The fixture's bundle is named ``legacy_component`` and declares the local + Need ``tool_req__legacy_component``. The matching rule uses exactly this + ``__`` relationship, so the metadata must be added + to that Need rather than to another Need from the imported input data. + """ + result = run_scenario("build", "reference_integration", ":needs_json") + assert result.artifacts is not None + + needs = load_needs(result.artifacts["needs.json"]) + # The ID is the expected match for the ``legacy_component`` bundle: + # ``tool_req`` is the Need type and ``legacy_component`` is the bundle name. + legacy_need = needs["tool_req__legacy_component"] + assert isinstance(legacy_need, dict) + assert ( + legacy_need["bazel_target"] + == "@@//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/" + "docs/components/component:component_sources" + ) + assert legacy_need["bazel_type"] == "filegroup" From 37558e54ca13133ebd7b1a5443a940fe28da4e77 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 23 Sep 2026 00:45:49 +0200 Subject: [PATCH 2/7] test: cover multiple bundle targets --- .../tests/test_bundle_lifecycle.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py index ee33cdabc..b269a3b9d 100644 --- a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py +++ b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py @@ -76,6 +76,27 @@ def _bundle() -> BundleMetadata: ) +def test_multiple_bundle_targets_keep_declaration_order_in_json() -> None: + """Multiple targets use matching compact JSON arrays in source order.""" + bundle = BundleMetadata( + label="//:memory", + name="memory", + code_targets=( + BazelTarget(label="//:memory_core", type="cc_library"), + BazelTarget(label="//:memory_test", type="cc_test"), + ), + ) + + values = bundle_metadata._render_target_values( # pyright: ignore[reportPrivateUsage] + bundle + ) + + assert values == { + "bazel_target": '["//:memory_core","//:memory_test"]', + "bazel_type": '["cc_library","cc_test"]', + } + + def test_matching_updates_need_in_place_and_returns_affected_document( monkeypatch: pytest.MonkeyPatch, ) -> None: From d738ce436df05751db2074368855ae69ccec213d Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 23 Sep 2026 10:15:18 +0200 Subject: [PATCH 3/7] test: update bundle metadata golden samples --- src/tests/docs_bzl/expected_outputs.py | 16 ++++++++++++++ .../_expected/needs_json/needs.json | 18 +++++++++++++++ .../_expected/data_bundle_needs/needs.json | 18 +++++++++++++++ .../isolated_source_bundle_needs/needs.json | 18 +++++++++++++++ .../_expected/needs_json/needs.json | 18 +++++++++++++++ .../_expected/needs_json/needs.json | 18 +++++++++++++++ .../_expected/needs_local.json | 18 +++++++++++++++ .../_expected/needs_json/needs.json | 20 +++++++++++++++++ .../component/_expected/needs_local.json | 22 ++++++++++++++++++- .../_expected/needs_json/needs.json | 18 +++++++++++++++ .../_expected/needs_local.json | 18 +++++++++++++++ .../_expected/needs_json/needs.json | 18 +++++++++++++++ .../consumer/_expected/needs_json/needs.json | 18 +++++++++++++++ .../producer/_expected/needs_json/needs.json | 18 +++++++++++++++ 14 files changed, 255 insertions(+), 1 deletion(-) diff --git a/src/tests/docs_bzl/expected_outputs.py b/src/tests/docs_bzl/expected_outputs.py index 6f7092520..f16210183 100644 --- a/src/tests/docs_bzl/expected_outputs.py +++ b/src/tests/docs_bzl/expected_outputs.py @@ -178,6 +178,22 @@ def discover_expected_outputs(scenario: str) -> list[ExpectedOutput]: short_name = matches[0] target = TARGETS[short_name] + # The component fixture uses a descriptive bundle target name because + # bundle metadata is matched against that name. Keep the generic + # ``needs_local`` contract name in the checked-in tree while resolving + # it to the fixture's actual internal target here. + if ( + scenario == "reference_integration/legacy_module/docs/components/component" + and short_name == "needs_local" + ): + target = ExpectedTarget( + label=":legacy_component.__internal__.needs_local", + command="build", + output_kind="file", + output_path=( + "legacy_component.__internal__.needs_local/_build/needs/needs.json" + ), + ) if target.output_kind == "directory" and not path.is_dir(): raise ValueError( f"expected output {path} must be a directory for target {target.label}" diff --git a/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json index f9f99829c..ecdce6758 100644 --- a/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/basic_docs/_expected/needs_json/needs.json @@ -104,6 +104,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json index abd52ca91..14e5dc98e 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/data_bundle_needs/needs.json @@ -104,6 +104,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json index 3fa5be430..a52093111 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/isolated_source_bundle_needs/needs.json @@ -104,6 +104,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json index 847698fe4..4574b9de2 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/_expected/needs_json/needs.json @@ -104,6 +104,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json index 745bfab3a..e7a2aab6a 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/needs_json/needs.json @@ -121,6 +121,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json index a7e834174..1a36614b4 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/_expected/needs_local.json @@ -104,6 +104,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json index 949930eeb..c44f152e4 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/_expected/needs_json/needs.json @@ -10,6 +10,8 @@ }, "needs": { "tool_req__legacy_component": { + "bazel_target": "@@//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:component_sources", + "bazel_type": "filegroup", "content": "The legacy component implementation is covered by the component source\ncode-link scan. The integration test checks that this link is preserved\nwhen the component is built through its module and by the full site.", "docname": "components/component/index", "external_css": "external_link", @@ -121,6 +123,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json index 4cab24c9b..a139f836d 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json @@ -1,6 +1,6 @@ { "current_version": "0.0.0", - "project": "docs_bundle", + "project": "legacy_component", "project_url": "", "versions": { "0.0.0": { @@ -10,6 +10,8 @@ }, "needs": { "tool_req__legacy_component": { + "bazel_target": "@@//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:component_sources", + "bazel_type": "filegroup", "content": "The legacy component implementation is covered by the component source\ncode-link scan. The integration test checks that this link is preserved\nwhen the component is built through its module and by the full site.", "docname": "index", "external_css": "external_link", @@ -121,6 +123,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json index df6189811..620480675 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json @@ -204,6 +204,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json index 477fa06de..89d49406a 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/unlinked_component/_expected/needs_local.json @@ -120,6 +120,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json index e62aa5ce3..c2d08a44c 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/score_platform/_expected/needs_json/needs.json @@ -148,6 +148,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json index 14b5ad062..7aa97354f 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/consumer/_expected/needs_json/needs.json @@ -150,6 +150,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json index 5c7780452..f3800a69e 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/_expected/needs_json/needs.json @@ -135,6 +135,24 @@ "null" ] }, + "bazel_target": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, + "bazel_type": { + "default": "", + "description": "Added by needs_fields config", + "field_type": "extra", + "type": [ + "string", + "null" + ] + }, "belongs_to": { "default": [], "description": "Link field", From 2bcc1b108a6594a80b9926d91a2c02a6312295ca Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 23 Sep 2026 15:53:55 +0200 Subject: [PATCH 4/7] refactor: clarify bundle target value encoding --- src/extensions/score_metamodel/bundle_metadata.py | 9 ++++----- .../score_metamodel/tests/test_bundle_lifecycle.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/extensions/score_metamodel/bundle_metadata.py b/src/extensions/score_metamodel/bundle_metadata.py index 6b37ab2ac..c3c648f85 100644 --- a/src/extensions/score_metamodel/bundle_metadata.py +++ b/src/extensions/score_metamodel/bundle_metadata.py @@ -54,13 +54,12 @@ class _BundleMetadataUpdate: changed_docnames: set[str] -def _render_target_values(bundle: BundleMetadata) -> dict[str, str]: - """Convert a bundle's Bazel targets to values stored on a Need. +def _encode_target_values(bundle: BundleMetadata) -> dict[str, str]: + """Encode a bundle's Bazel target metadata for storage in Need fields. This is called for an unambiguous bundle match during ``env-updated``. Bundle metadata is the authoritative source for these extension-owned - fields, so the current targets must be rendered before they are written to - the Need. A single target keeps the existing scalar representation; + fields. A single target keeps the existing scalar representation; multiple targets use compact JSON because the Need fields are strings. A single target is stored as a plain label and rule type. Multiple targets @@ -183,7 +182,7 @@ def _apply_matching_bundles( need_id = str(matching_need["id"]) changed = _update_bundle_values( matching_need, - _render_target_values(bundle), + _encode_target_values(bundle), ) matched_need_ids.add(need_id) if changed: diff --git a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py index b269a3b9d..367c17087 100644 --- a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py +++ b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py @@ -87,7 +87,7 @@ def test_multiple_bundle_targets_keep_declaration_order_in_json() -> None: ), ) - values = bundle_metadata._render_target_values( # pyright: ignore[reportPrivateUsage] + values = bundle_metadata._encode_target_values( # pyright: ignore[reportPrivateUsage] bundle ) From 0eafa634b29607b47d99d573e5993c38ba9d6d35 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 23 Sep 2026 15:59:37 +0200 Subject: [PATCH 5/7] test: cover external bundle metadata --- .../score_metamodel/bundle_metadata.py | 16 +++++---- .../tests/test_bundle_lifecycle.py | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/extensions/score_metamodel/bundle_metadata.py b/src/extensions/score_metamodel/bundle_metadata.py index c3c648f85..77ff36bc2 100644 --- a/src/extensions/score_metamodel/bundle_metadata.py +++ b/src/extensions/score_metamodel/bundle_metadata.py @@ -129,9 +129,12 @@ def _group_bundle_needs( can occur in different documents, while a bundle must only receive metadata for Needs declared in its own documents. - Imported and external Needs are excluded: a bundle must only receive - metadata for Needs declared in its own documents, not for requirements - copied in from another documentation bundle. + Imported Needs and external-reference Needs are excluded: a bundle must + only receive metadata for Needs declared in its own documents, not for + requirements copied in from another documentation bundle. A bundle + mounted from another repository is still parsed into this Sphinx build, so + its local Needs are intentionally handled like Needs from an in-tree + bundle. """ grouped: dict[str, list[NeedItem]] = defaultdict(list) bundle_by_label: dict[str, BundleMetadata] = {} @@ -208,9 +211,10 @@ def _clear_unmatched_bundle_metadata( This must run once, after ``_apply_matching_bundles`` has seen every bundle; running it earlier could clear a Need before a later bundle has a chance to match it. It is necessary because Sphinx reuses Needs from - unchanged documents, so removed or renamed bundles otherwise leave their - old generated Bazel values behind. The returned document names tell - Sphinx to include documents affected by that cleanup in the rebuild. + unchanged documents, including documents mounted from external bundles, + so removed or renamed bundles otherwise leave their old generated Bazel + values behind. The returned document names tell Sphinx to include + documents affected by that cleanup in the rebuild. """ changed_docnames: set[str] = set() for need_id, current_need in needs.items(): diff --git a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py index 367c17087..29240d087 100644 --- a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py +++ b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py @@ -133,6 +133,39 @@ def add_need(self, _need: NeedItem) -> None: assert need["bazel_type"] == "cc_library" +def test_external_bundle_updates_local_need_like_internal_bundle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mounted external bundle receives metadata through the same path.""" + need = _need() + + class FakeNeedsData: + def __init__(self, _env: object) -> None: + self.needs = {"comp__memory": need} + + def get_needs_mutable(self): + return self.needs + + external_bundle = BundleMetadata( + label="@@external_repo//:memory", + name="memory", + code_targets=_bundle().code_targets, + ) + monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) + monkeypatch.setattr( + bundle_metadata, + "get_document_bundles", + lambda _app: {"index": external_bundle}, + ) + app = SimpleNamespace(env=SimpleNamespace(), srcdir=".") + + changed = bundle_metadata.apply_bundle_metadata(cast(Sphinx, app), None) + + assert changed == ["index"] + assert need["bazel_target"] == "//:memory_core" + assert need["bazel_type"] == "cc_library" + + def test_unchanged_bundle_metadata_does_not_rewrite_the_document( monkeypatch: pytest.MonkeyPatch, ) -> None: From 88765cef21218186f96ed85c0eae6a9701959a04 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 24 Sep 2026 13:14:57 +0200 Subject: [PATCH 6/7] feat: use explicit primary Need for bundle metadata --- bzl/bundle_rules.bzl | 16 ++- bzl/mount_rules.bzl | 4 + docs.bzl | 12 ++ docs/how-to/source_to_doc_links.rst | 6 + docs/reference/bazel_macros.rst | 13 ++- src/extensions/docs/mounts_internals.rst | 8 +- .../score_metamodel/bundle_matching.py | 99 ---------------- .../score_metamodel/bundle_metadata.py | 106 ++++++++++-------- .../tests/test_bundle_lifecycle.py | 17 +-- .../tests/test_bundle_matching.py | 94 ---------------- src/extensions/score_mounts/_resolver.py | 10 +- .../score_mounts/tests/test_resolver.py | 2 + src/tests/docs_bzl/expected_outputs.py | 16 --- .../_expected/mounts_manifest.json | 9 +- .../_expected/ordered_aggregate_manifest.json | 4 +- .../reference_integration/legacy_module/BUILD | 2 +- .../docs/components/component/BUILD | 5 +- .../component/_expected/needs_local.json | 2 +- .../reference_integration/modern_module/BUILD | 2 +- .../_expected/needs_json/needs.json | 2 + .../docs/components/component/BUILD | 6 +- .../docs_bzl/test_reference_integration.py | 14 +-- 22 files changed, 164 insertions(+), 285 deletions(-) delete mode 100644 src/extensions/score_metamodel/bundle_matching.py delete mode 100644 src/extensions/score_metamodel/tests/test_bundle_matching.py diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 54416c805..7e60ba7d0 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -39,9 +39,9 @@ DocsBundleInfo = provider( doc = "A documentation bundle with its source and placement metadata.", fields = { # Each entry carries the source and placement information needed by - # the runtime manifest, plus the identity and direct-target metadata - # of the bundle that declared it. - "entries": "Ordered entries with source, placement, and direct-target metadata.", + # the runtime manifest, plus the identity, primary Need, and + # direct-target metadata of the bundle that declared it. + "entries": "Ordered entries with source, placement, primary Need, and direct-target metadata.", "own_source_files": "This bundle's direct source files, excluding nested bundles.", "source_dir_execroot_path": "Execution-root-relative path of this bundle's direct source root.", "sourcelinks": "Source-code-link JSON files together with their owning repository.", @@ -259,6 +259,7 @@ def _rebase_bundle_entry(entry, mount_at, attach_to, toctree_index): # the composition graph. bundle_label = entry.bundle_label, bundle_name = entry.bundle_name, + primary_need_id = entry.primary_need_id, code_targets = entry.code_targets, # This entry is now part of a parent composition. It may have been the # root of its own standalone bundle, but it is a child entry here and @@ -318,6 +319,7 @@ def _docs_bundle_impl(ctx): own_data = depset(direct = ctx.files.data) own_bundle_label = str(ctx.label) own_bundle_name = ctx.label.name + own_primary_need_id = ctx.attr.primary_need_id own_code_targets = [ struct( label = str(target.label), @@ -355,6 +357,7 @@ def _docs_bundle_impl(ctx): data = own_data, bundle_label = own_bundle_label, bundle_name = own_bundle_name, + primary_need_id = own_primary_need_id, # This direct entry belongs to the current composition's root # bundle. _rebase_bundle_entry changes this to false if a parent # embeds the bundle as a child. @@ -397,6 +400,7 @@ def _docs_bundle_impl(ctx): data = own_data, bundle_label = own_bundle_label, bundle_name = own_bundle_name, + primary_need_id = own_primary_need_id, # This direct entry belongs to the current composition's root # bundle. _rebase_bundle_entry changes this to false if a parent # embeds the bundle as a child. @@ -428,6 +432,7 @@ def _docs_bundle_impl(ctx): data = own_data, bundle_label = own_bundle_label, bundle_name = own_bundle_name, + primary_need_id = own_primary_need_id, # This direct entry belongs to the current composition's root # bundle. _rebase_bundle_entry changes this to false if a parent # embeds the bundle as a child. @@ -494,6 +499,9 @@ _docs_bundle = rule( # those cases do not call _source_dir_runtime_path(). "source_dir": attr.string(default = ""), "entry_doc": attr.string(default = "index"), + # Optional explicit association with the Need representing this + # bundle. The value is a Sphinx-Needs ID, not a Bazel label. + "primary_need_id": attr.string(default = ""), "bundles": attr.label_list(providers = [DocsBundleInfo]), "bundle_mount_ats": attr.string_list(), "bundle_attach_tos": attr.string_list(), @@ -514,6 +522,7 @@ def create_bundle( sourcelinks_json = None, source_dir = None, entry_doc = "index", + primary_need_id = "", data = [], code_targets = [], visibility = None, @@ -531,6 +540,7 @@ def create_bundle( sourcelinks_json = sourcelinks_json, source_dir = source_dir if source_dir != None else "", entry_doc = entry_doc, + primary_need_id = primary_need_id, bundles = [bundle.bundle for bundle in parsed_bundles], bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles], bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles], diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index 0bf9b976d..2b5d5a410 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -48,6 +48,10 @@ def _composition_manifest_impl(ctx): "bundle": { "label": entry.bundle_label, "name": entry.bundle_name, + # This explicit ID identifies the primary Need represented by + # the bundle. It is intentionally independent of the Bazel + # target name and of any other Need IDs in the bundle. + "primary_need_id": entry.primary_need_id, # Each direct target contributes its Bazel label and rule kind # to the bundle metadata consumed by Python. "code_targets": [ diff --git a/docs.bzl b/docs.bzl index 427a0daa3..c572cca2f 100644 --- a/docs.bzl +++ b/docs.bzl @@ -226,6 +226,7 @@ def _declare_docs_bundle( entry_doc = "index", bundles = [], code_targets = [], + primary_need_id = "", visibility = None, **kwargs): """Declare the shared bundle target implementation. @@ -263,6 +264,9 @@ def _declare_docs_bundle( code_targets: Implementation targets or filegroups to scan for source-code links. Implementation target source files and their dependencies are collected recursively; filegroups expand to their files. + primary_need_id: Sphinx-Needs ID of the primary Need representing this + bundle. Other Needs may remain in the bundle; only this + Need receives bundle-level target metadata. visibility: Target visibility. **kwargs: Additional attributes forwarded to the underlying rule. """ @@ -305,6 +309,7 @@ def _declare_docs_bundle( bundles = bundles, data = bundle_data, code_targets = code_targets, + primary_need_id = primary_need_id, visibility = visibility, **kwargs ) @@ -398,6 +403,7 @@ def docs_bundle( entry_doc = "index", bundles = [], code_targets = [], + primary_need_id = "", visibility = None, **kwargs): """Declare a reusable documentation bundle. @@ -415,6 +421,7 @@ def docs_bundle( entry_doc = entry_doc, bundles = bundles, code_targets = code_targets, + primary_need_id = primary_need_id, visibility = visibility, **kwargs ) @@ -500,6 +507,7 @@ def docs( deps = [], external_needs = [], code_targets = [], + primary_need_id = "", test_sources = [], known_good = None, metamodel = None, @@ -523,6 +531,9 @@ def docs( code_targets: Implementation targets or filegroups to scan for source code links. Implementation targets are scanned recursively; filegroups expand to their files. + primary_need_id: Sphinx-Needs ID of the primary Need representing this + documentation project and its root bundle. Other Needs + remain unchanged. test_sources: Optional list of repo-relative directory paths which will be used to filter testcases for documentation generation. When empty (default), all testcases found in `bazel-testlogs` will be used. known_good: Optional label to a "known good" JSON file for source links. @@ -586,6 +597,7 @@ def docs( entry_doc = "index", bundles = bundles, code_targets = code_targets, + primary_need_id = primary_need_id, visibility = ["//visibility:public"], tags = ["manual"] ) diff --git a/docs/how-to/source_to_doc_links.rst b/docs/how-to/source_to_doc_links.rst index 4ddee27fd..8e28efca4 100644 --- a/docs/how-to/source_to_doc_links.rst +++ b/docs/how-to/source_to_doc_links.rst @@ -68,4 +68,10 @@ uses. You may also pass filegroups; their files are scanned directly. ], source_dir = "docs", code_targets = [":some_application"], + primary_need_id = "comp__some_component", ) + +``primary_need_id`` is optional and identifies the single Need representing +the bundle's primary subject. A bundle can contain many other Needs; target +metadata is attached only to this explicitly selected Need. The ID is never +derived from the Bazel bundle name. diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index da25e2839..9fd8b22d9 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -116,6 +116,11 @@ Minimal example (root ``BUILD``) generated JSON is supplied to ``live_preview`` just like a normal documentation build. +- ``primary_need_id`` (string, optional) + ID of the Sphinx-Needs item that represents the primary subject of this + documentation project. A project may contain many other Needs; when this + value is set, bundle-level target metadata is written only to this Need. + - ``external_needs`` (list of bazel labels) External ``:needs_json_file`` targets from other modules/repositories for referencing their needs. @@ -166,7 +171,7 @@ site). visibility = ["//visibility:public"], ) -Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_doc = "index", bundles = [], code_targets = [], visibility = None)``. +Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_doc = "index", bundles = [], code_targets = [], primary_need_id = "", visibility = None)``. - ``source_dir`` (string, optional) Directory holding the bundle's own doc sources. It is globbed the same way as @@ -242,3 +247,9 @@ Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_do recursively from their ``deps``; filegroups expand to their files. The bundle owns one cached scan result; Bazel only regenerates it when its collected source inputs change. + +- ``primary_need_id`` (string, optional) + ID of the local Sphinx-Needs item representing the primary subject of this + bundle. The bundle may contain many additional Needs; only this explicitly + selected Need receives bundle-level target metadata. The ID is independent + of the Bazel bundle target name. diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index f1b8d7e87..a14067286 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -39,7 +39,8 @@ Each manifest entry contains: * ``mount_at`` and ``attach_to`` — the already-composed Sphinx placement; and * ``entry_doc`` — the canonical entry document declared by the source bundle. * ``external`` — whether the directory belongs to another Bazel module; -* ``bundle`` — the declaring bundle's Bazel label, name, and direct targets; +* ``bundle`` — the declaring bundle's Bazel label, name, explicit primary Need + ID, and direct targets; * ``root_bundle`` — whether this physical entry belongs to the root bundle of the current composition. This is composition-specific: the same bundle can be a root in a standalone manifest and a child in another composition. @@ -72,8 +73,9 @@ the mount integration's recorded output, while primary docnames come from Sphinx's discovery set. Documents discovered through data-only mounts are intentionally omitted because those mounts provide auxiliary files rather than bundle-owned source roots. This avoids assigning a bundle to skipped, -conflicting, or data-only mounts. The matcher in ``score_metamodel`` will -consume this mapping without inspecting paths or mount configuration. +conflicting, or data-only mounts. The bundle metadata pass in +``score_metamodel`` consumes this mapping without inspecting paths or mount +configuration. The rule rejects conflicting final placements before Sphinx starts. A mount without ``attach_to`` is attached to the ``index`` document beside its diff --git a/src/extensions/score_metamodel/bundle_matching.py b/src/extensions/score_metamodel/bundle_matching.py deleted file mode 100644 index 31568e415..000000000 --- a/src/extensions/score_metamodel/bundle_matching.py +++ /dev/null @@ -1,99 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* -"""Find the local Sphinx-Needs item belonging to a documentation bundle.""" - -from __future__ import annotations - -from collections.abc import Iterable, Mapping -from dataclasses import dataclass - -from sphinx_needs.need_item import NeedItem - - -@dataclass(frozen=True) -class BundleNeedMatch: - """Result of matching one bundle against its local Needs. - - This result is created during the ``env-updated`` pass, after Sphinx has - collected the current Needs. Keeping either the unambiguous Need ID or a - diagnostic in one object lets the caller decide whether it is safe to - write bundle metadata without guessing on an ambiguous match. - - Exactly one of ``need_id`` and ``error`` is populated. The error is already - formatted for the diagnostic emitted by the caller. - """ - - need_id: str | None = None - error: str | None = None - - def __post_init__(self) -> None: - """Reject a result that is neither a match nor a diagnostic.""" - if bool(self.need_id) == bool(self.error): - raise ValueError("exactly one of need_id and error must be non-empty") - - -def match_bundle_to_need( - bundle_name: str, - own_needs: Iterable[NeedItem | Mapping[str, object]], -) -> BundleNeedMatch: - """Find the Need whose ID follows ``__``. - - The bundle-metadata callback calls this once per current bundle after it - has grouped the Needs declared in that bundle's documents. The exact ID - and unique-type checks are necessary because a bundle name alone is not a - safe global identifier; when they fail, the caller must leave the Need - unchanged rather than attach metadata to an arbitrary Need. - - The caller supplies only the Needs declared in the bundle's own documents. - The function deliberately does not guess when no Need matches, when more - than one Need type uses the same bundle name, or when the type is not - unique within the bundle. - """ - needs = list(own_needs) - - # A Need belongs to this bundle only when its ID combines its type with - # the bundle name, for example ``comp__memory`` for ``memory``. - candidates = sorted( - (need for need in needs if need["id"] == f"{need['type']}__{bundle_name}"), - key=lambda need: str(need["id"]), - ) - candidate_ids = tuple(str(need["id"]) for need in candidates) - if not candidates: - return BundleNeedMatch( - error=f"no Need has the exact ID suffix for {bundle_name!r}" - ) - - # Never choose between multiple Needs that claim the same bundle name. - if len(candidates) != 1: - return BundleNeedMatch( - error="multiple Needs have the exact bundle name: " - + ", ".join(candidate_ids) - ) - - candidate = candidates[0] - candidate_type = str(candidate["type"]) - - # A single name match is safe only when that Need type is unique in the - # bundle; otherwise another Need of the same type could be the intended one. - same_type = sorted( - (need for need in needs if need.get("type") == candidate_type), - key=lambda need: str(need["id"]), - ) - conflicting_ids = tuple(str(need["id"]) for need in same_type) - if len(same_type) != 1: - return BundleNeedMatch( - error="Need type is not unique in the bundle: " + ", ".join(conflicting_ids) - ) - - # The name and type checks above leave exactly one unambiguous Need. - return BundleNeedMatch(need_id=str(candidate["id"])) diff --git a/src/extensions/score_metamodel/bundle_metadata.py b/src/extensions/score_metamodel/bundle_metadata.py index 77ff36bc2..8b5014fde 100644 --- a/src/extensions/score_metamodel/bundle_metadata.py +++ b/src/extensions/score_metamodel/bundle_metadata.py @@ -13,9 +13,9 @@ """Copy Bazel target information from documentation bundles to local Needs. Each documentation bundle can own one or more Sphinx documents and can expose -the Bazel targets that produced its source files. This module finds the Needs -declared in those documents, adds the target information to the matching Need, -and removes the values from local Needs that no longer have a matching bundle. +the Bazel targets that produced its source files. This module adds those +targets to the explicitly configured primary Need of the bundle and removes +the values from local Needs that no longer have a current bundle association. """ from __future__ import annotations @@ -29,7 +29,6 @@ from sphinx_needs.data import SphinxNeedsData from sphinx_needs.need_item import NeedItem -from src.extensions.score_metamodel.bundle_matching import match_bundle_to_need from src.extensions.score_mounts import get_document_bundles from src.extensions.score_mounts._resolver import BundleMetadata @@ -40,11 +39,13 @@ class _BundleMetadataUpdate: """Results of applying bundle metadata to the current Needs. - This object exists only for one ``env-updated`` pass. Matching must finish - before cleanup can run, so the cleanup step needs both the complete set of - successful matches and the documents changed while applying them. + This object exists only for one ``env-updated`` pass. Primary Need + association must finish before cleanup can run, so the cleanup step needs + both the complete set of successful associations and the documents changed + while applying them. - ``matched_need_ids`` identifies Needs whose bundle still matches, and + ``matched_need_ids`` identifies Needs whose explicit bundle association is + still current, and ``changed_docnames`` contains documents whose Need values were changed. The latter is returned to Sphinx so those documents can be considered changed by the build. @@ -57,7 +58,8 @@ class _BundleMetadataUpdate: def _encode_target_values(bundle: BundleMetadata) -> dict[str, str]: """Encode a bundle's Bazel target metadata for storage in Need fields. - This is called for an unambiguous bundle match during ``env-updated``. + This is called for a bundle with a valid explicit primary Need during + ``env-updated``. Bundle metadata is the authoritative source for these extension-owned fields. A single target keeps the existing scalar representation; multiple targets use compact JSON because the Need fields are strings. @@ -78,12 +80,12 @@ def _encode_target_values(bundle: BundleMetadata) -> dict[str, str]: def _clear_bundle_values(need: NeedItem) -> bool: - """Clear extension-owned values from a Need with no current match. + """Clear extension-owned values from a Need with no current association. - This is used during the final cleanup step of ``env-updated``. Sphinx + This is used during the final cleanup step of ``env-updated``. Sphinx keeps Needs from unchanged documents in its environment, so a value that was correct in an earlier build can remain after a bundle is renamed, - removed, or can no longer be matched. The return value tells the caller + removed, or no longer declares the Need. The return value tells the caller whether the owning document must be reported as changed. """ changed = False @@ -100,9 +102,10 @@ def _update_bundle_values( ) -> bool: """Apply current bundle values and report whether the Need was changed. - This is called for every unambiguous match during ``env-updated``. The - bundle is authoritative for these generated fields, so current values are - written even when an older value is already present. The boolean is + This is called for every valid primary Need association during + ``env-updated``. The bundle is authoritative for these generated fields, + so current values are written even when an older value is already present. + The boolean is needed only to avoid asking Sphinx to rebuild a document whose Need values are already current. """ @@ -122,12 +125,12 @@ def _group_bundle_needs( owners: dict[str, BundleMetadata], needs: dict[str, NeedItem], ) -> tuple[dict[str, BundleMetadata], dict[str, list[NeedItem]]]: - """Collect each bundle's own Needs before matching by bundle name. + """Collect each bundle's own Needs before resolving explicit primary IDs. This runs at the start of ``env-updated`` from the current Sphinx - environment. Matching needs this grouping because the same Need ID shape - can occur in different documents, while a bundle must only receive - metadata for Needs declared in its own documents. + environment. Resolution needs this grouping because a primary Need must + be declared by the bundle's own documents, not merely be present somewhere + in the composed Sphinx environment. Imported Needs and external-reference Needs are excluded: a bundle must only receive metadata for Needs declared in its own documents, not for @@ -155,36 +158,49 @@ def _group_bundle_needs( return bundle_by_label, grouped -def _apply_matching_bundles( +def _apply_primary_bundle_metadata( bundles: dict[str, BundleMetadata], grouped: dict[str, list[NeedItem]], ) -> _BundleMetadataUpdate: - """Add each bundle's target information to its uniquely matching Need. + """Add each bundle's target information to its explicitly selected Need. This runs after ``_group_bundle_needs`` and before cleanup in the same - ``env-updated`` pass. It records only successful matches so cleanup can - distinguish a Need that still has a valid bundle from one carrying stale - metadata. It also records changed documents because Sphinx tracks rebuilds - by document, not by individual Need. + ``env-updated`` pass. It records only successful associations so cleanup + can distinguish a Need that still has a valid bundle from one carrying + stale metadata. It also records changed documents because Sphinx tracks + rebuilds by document, not by individual Need. """ matched_need_ids: set[str] = set() changed_docnames: set[str] = set() for bundle_label, bundle in sorted(bundles.items()): - result = match_bundle_to_need(bundle.name, grouped.get(bundle_label, [])) - if result.error: + if not bundle.primary_need_id: logger.info( f"bundle {bundle.label!r} ({bundle.name!r}) has direct code targets " - f"but cannot be associated with one local Need: {result.error}", + "but no primary_need_id; bundle metadata is not attached to a Need", type="score_metamodel", ) continue - matching_need = next( - need for need in grouped[bundle_label] if need["id"] == result.need_id + primary_need = next( + ( + need + for need in grouped.get(bundle_label, []) + if str(need.get("id", "")) == bundle.primary_need_id + ), + None, ) - need_id = str(matching_need["id"]) + if primary_need is None: + logger.error( + f"bundle {bundle.label!r} ({bundle.name!r}) declares primary_need_id " + f"{bundle.primary_need_id!r}, but that Need is not declared by the " + "bundle's own documents", + type="score_metamodel", + ) + continue + + need_id = str(primary_need["id"]) changed = _update_bundle_values( - matching_need, + primary_need, _encode_target_values(bundle), ) matched_need_ids.add(need_id) @@ -192,7 +208,7 @@ def _apply_matching_bundles( # Sphinx tracks changes by document, while the metadata is stored # on individual Needs. Rebuild the owning document when one of its # Need values changes. - docname = matching_need.get("docname") + docname = primary_need.get("docname") if isinstance(docname, str): changed_docnames.add(docname) @@ -202,13 +218,13 @@ def _apply_matching_bundles( ) -def _clear_unmatched_bundle_metadata( +def _clear_unassociated_bundle_metadata( needs: dict[str, NeedItem], matched_need_ids: set[str], ) -> set[str]: - """Remove stale bundle metadata after all current bundles were matched. + """Remove stale bundle metadata after all current bundles were processed. - This must run once, after ``_apply_matching_bundles`` has seen every + This must run once, after ``_apply_primary_bundle_metadata`` has seen every bundle; running it earlier could clear a Need before a later bundle has a chance to match it. It is necessary because Sphinx reuses Needs from unchanged documents, including documents mounted from external bundles, @@ -235,20 +251,22 @@ def apply_bundle_metadata(app: Sphinx, _: object) -> list[str]: """Update local Needs after Sphinx has collected the current documents. This is the ``env-updated`` callback and runs after collection and other - extensions have finished updating the environment. It gets the current - bundle ownership map, matches bundles to their own Needs, updates the - generated Bazel fields, then removes stale values from unmatched local - Needs. It returns the affected documents because Sphinx uses callback - return values to decide which documents need to be written again. + extensions have finished updating the environment. It gets the current + bundle ownership map, resolves each configured primary Need, updates the + generated Bazel fields, then removes stale values from local Needs without + a current association. It returns the affected documents because Sphinx + uses callback return values to decide which documents need to be written + again. """ owners = get_document_bundles(app) needs_data = SphinxNeedsData(app.env) needs = needs_data.get_needs_mutable() bundles, grouped = _group_bundle_needs(owners, needs) - update = _apply_matching_bundles(bundles, grouped) + update = _apply_primary_bundle_metadata(bundles, grouped) # A document is changed both when new metadata is written and when stale - # metadata is removed because its bundle no longer matches. + # metadata is removed because its bundle no longer has a current + # association. update.changed_docnames.update( - _clear_unmatched_bundle_metadata(needs, update.matched_need_ids) + _clear_unassociated_bundle_metadata(needs, update.matched_need_ids) ) return sorted(update.changed_docnames) diff --git a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py index 29240d087..790e6798e 100644 --- a/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py +++ b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py @@ -68,10 +68,11 @@ def _need( def _bundle() -> BundleMetadata: - """Create a bundle named ``memory`` with one direct Bazel target.""" + """Create a bundle with an explicitly selected primary Need.""" return BundleMetadata( label="//:memory", - name="memory", + name="unrelated_bundle_name", + primary_need_id="comp__memory", code_targets=(BazelTarget(label="//:memory_core", type="cc_library"),), ) @@ -97,10 +98,10 @@ def test_multiple_bundle_targets_keep_declaration_order_in_json() -> None: } -def test_matching_updates_need_in_place_and_returns_affected_document( +def test_explicit_primary_need_is_updated_in_place_and_returns_affected_document( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The ``memory`` bundle adds its target to the local ``comp__memory`` Need.""" + """The configured primary Need receives the bundle's direct target.""" need = _need() original_content = need.content @@ -148,7 +149,8 @@ def get_needs_mutable(self): external_bundle = BundleMetadata( label="@@external_repo//:memory", - name="memory", + name="another_unrelated_name", + primary_need_id="comp__memory", code_targets=_bundle().code_targets, ) monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) @@ -197,10 +199,10 @@ def get_needs_mutable(self): assert need["bazel_type"] == "cc_library" -def test_bundle_metadata_is_cleared_when_matching_is_lost( +def test_bundle_metadata_is_cleared_when_primary_need_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A renamed bundle no longer matches and loses its old metadata.""" + """A missing primary Need association loses stale bundle metadata.""" need = _need( bazel_target="//:memory_core", bazel_type="cc_library", @@ -216,6 +218,7 @@ def get_needs_mutable(self): unmatched_bundle = BundleMetadata( label="//:memory", name="other", + primary_need_id="comp__other", code_targets=_bundle().code_targets, ) monkeypatch.setattr(bundle_metadata, "SphinxNeedsData", FakeNeedsData) diff --git a/src/extensions/score_metamodel/tests/test_bundle_matching.py b/src/extensions/score_metamodel/tests/test_bundle_matching.py deleted file mode 100644 index 882e3942b..000000000 --- a/src/extensions/score_metamodel/tests/test_bundle_matching.py +++ /dev/null @@ -1,94 +0,0 @@ -# ******************************************************************************* -# 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 -# ******************************************************************************* - -from src.extensions.score_metamodel.bundle_matching import match_bundle_to_need - - -def need(*, need_id: str, need_type: str) -> dict[str, str]: - """Represent the matching fields of one local Sphinx-Needs item.""" - return {"id": need_id, "type": need_type} - - -def test_matches_the_exact_name_and_unique_type() -> None: - """The ``memory`` bundle matches the only local Need named ``memory``.""" - bundle_name = "memory" - own_needs = [ - # This Need has the expected ``__`` ID. - need(need_id="comp__memory", need_type="comp"), - # This Need belongs to another bundle and must not affect the match. - need(need_id="feat__storage", need_type="feat"), - ] - - result = match_bundle_to_need(bundle_name, own_needs) - - assert result.need_id == "comp__memory" - - -def test_does_not_match_case_insensitively() -> None: - """A bundle name must match the Need ID with the same casing.""" - bundle_name = "memory" - own_needs = [ - # ``comp__Memory`` is not the exact ID for the ``memory`` bundle. - need(need_id="comp__Memory", need_type="comp"), - ] - - result = match_bundle_to_need(bundle_name, own_needs) - - assert result.error == "no Need has the exact ID suffix for 'memory'" - - -def test_rejects_multiple_matching_names_across_types() -> None: - """The matcher refuses when two Need types claim the same bundle.""" - bundle_name = "memory" - own_needs = [ - need(need_id="comp__memory", need_type="comp"), - need(need_id="feat__memory", need_type="feat"), - ] - - result = match_bundle_to_need(bundle_name, own_needs) - - # Both IDs match the bundle name, so choosing one type would be arbitrary. - assert ( - result.error - == "multiple Needs have the exact bundle name: comp__memory, feat__memory" - ) - - -def test_rejects_a_second_need_of_the_matching_type() -> None: - """The matching Need type must be unique within the bundle.""" - bundle_name = "memory" - own_needs = [ - need(need_id="comp__memory", need_type="comp"), - need(need_id="comp__other", need_type="comp"), - ] - - result = match_bundle_to_need(bundle_name, own_needs) - - # The name identifies ``comp__memory``, but the ``comp`` type is not unique. - assert ( - result.error - == "Need type is not unique in the bundle: comp__memory, comp__other" - ) - - -def test_does_not_use_an_arbitrary_need_as_fallback() -> None: - """A different Need name is not used as a fallback match.""" - bundle_name = "memory" - own_needs = [ - # There is a component Need, but it belongs to ``other``, not ``memory``. - need(need_id="comp__other", need_type="comp"), - ] - - result = match_bundle_to_need(bundle_name, own_needs) - - assert result.error == "no Need has the exact ID suffix for 'memory'" diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index 0901f0585..801ca8e51 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -52,7 +52,7 @@ def from_manifest_entry(cls, entry: dict[str, str]) -> BazelTarget: @dataclass(frozen=True) class BundleMetadata: - """Identity and direct targets of one bundle in a composition. + """Identity, primary Need, and direct targets of one bundle in a composition. ``MountSpec`` describes one physical source entry and its placement. ``BundleMetadata`` describes the logical bundle that declared that entry. @@ -70,6 +70,10 @@ class BundleMetadata: # ``(BazelTarget("@@//score/components/memory:implementation", "cc_library"),)``. # Targets inherited from dependencies or nested bundles do not belong here. code_targets: tuple[BazelTarget, ...] = () + # The local Sphinx-Needs ID representing the primary subject of this + # bundle. A bundle may contain many other Needs; this identifies the one + # that receives metadata belonging to the bundle itself. + primary_need_id: str = "" @classmethod def from_manifest_entry(cls, entry: dict[str, object]) -> BundleMetadata: @@ -79,6 +83,10 @@ def from_manifest_entry(cls, entry: dict[str, object]) -> BundleMetadata: return cls( label=cast("str", bundle["label"]), name=cast("str", bundle["name"]), + # Older manifests do not carry an explicit primary Need. Treat + # the field as absent so those manifests remain readable while + # avoiding any name-based fallback. + primary_need_id=cast("str", bundle.get("primary_need_id", "")), code_targets=tuple( BazelTarget.from_manifest_entry(target) for target in targets ), diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index 390a8221b..7efd83afb 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -133,6 +133,7 @@ def test_load_bundle_metadata_and_direct_targets(tmp_path: Path) -> None: "bundle": { "label": "@@//pkg:memory", "name": "memory", + "primary_need_id": "comp__memory", "code_targets": [ {"label": "@@//pkg:memory_core", "type": "cc_library"}, {"label": "@@//pkg:memory_api", "type": "cc_library"}, @@ -149,6 +150,7 @@ def test_load_bundle_metadata_and_direct_targets(tmp_path: Path) -> None: assert result.mounts[0].bundle == BundleMetadata( label="@@//pkg:memory", name="memory", + primary_need_id="comp__memory", code_targets=( BazelTarget(label="@@//pkg:memory_core", type="cc_library"), BazelTarget(label="@@//pkg:memory_api", type="cc_library"), diff --git a/src/tests/docs_bzl/expected_outputs.py b/src/tests/docs_bzl/expected_outputs.py index f16210183..6f7092520 100644 --- a/src/tests/docs_bzl/expected_outputs.py +++ b/src/tests/docs_bzl/expected_outputs.py @@ -178,22 +178,6 @@ def discover_expected_outputs(scenario: str) -> list[ExpectedOutput]: short_name = matches[0] target = TARGETS[short_name] - # The component fixture uses a descriptive bundle target name because - # bundle metadata is matched against that name. Keep the generic - # ``needs_local`` contract name in the checked-in tree while resolving - # it to the fixture's actual internal target here. - if ( - scenario == "reference_integration/legacy_module/docs/components/component" - and short_name == "needs_local" - ): - target = ExpectedTarget( - label=":legacy_component.__internal__.needs_local", - command="build", - output_kind="file", - output_path=( - "legacy_component.__internal__.needs_local/_build/needs/needs.json" - ), - ) if target.output_kind == "directory" and not path.is_dir(): raise ValueError( f"expected output {path} must be a directory for target {target.label}" diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json index fffef9239..63f7eab6b 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json +++ b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/mounts_manifest.json @@ -10,7 +10,8 @@ } ], "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:docs_bundle", - "name": "docs_bundle" + "name": "docs_bundle", + "primary_need_id": "" }, "data": [], "entry_doc": "index", @@ -28,7 +29,8 @@ "bundle": { "code_targets": [], "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:parent", - "name": "parent" + "name": "parent", + "primary_need_id": "" }, "data": [ "src/tests/docs_bzl/scenarios/nested_bundles/generated/generated_output.txt" @@ -61,7 +63,8 @@ } ], "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:child", - "name": "child" + "name": "child", + "primary_need_id": "" }, "data": [], "entry_doc": "landing", diff --git a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json index 5d55492b7..410e91ef8 100644 --- a/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json +++ b/src/tests/docs_bzl/scenarios/nested_bundles/_expected/ordered_aggregate_manifest.json @@ -6,7 +6,8 @@ "bundle": { "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:other", "name": "other", - "code_targets": [] + "code_targets": [], + "primary_need_id": "" }, "data": [], "entry_doc": "index", @@ -24,6 +25,7 @@ "bundle": { "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:child", "name": "child", + "primary_need_id": "", "code_targets": [ { "label": "@@//src/tests/docs_bzl/scenarios/nested_bundles:example_binary", diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD index 2e8746064..92a53a08c 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD @@ -36,7 +36,7 @@ docs( "@score_process_description//:needs_json", ], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:legacy_component", + "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component:docs_bundle", "mount_at": "components/component", "attach_to": "components", }], diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD index 13cf64e03..9fdd2f011 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD @@ -23,8 +23,11 @@ filegroup( # ``code_targets`` so its traceability annotation exercises the source-link # generation path used by real component bundles. docs_bundle( - name = "legacy_component", + # Deliberately keep the Bazel target name independent from the Need ID. + # The explicit primary_need_id is the only association used for metadata. + name = "docs_bundle", source_dir = ".", code_targets = [":component_sources"], + primary_need_id = "tool_req__legacy_component", visibility = ["//visibility:public"], ) diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json index a139f836d..4277e64b3 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/_expected/needs_local.json @@ -1,6 +1,6 @@ { "current_version": "0.0.0", - "project": "legacy_component", + "project": "docs_bundle", "project_url": "", "versions": { "0.0.0": { diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD index a21b991fb..43da4e4b9 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD @@ -29,7 +29,7 @@ docs( "@score_process_description//:needs_json", ], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component:modern_component", + "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component:docs_bundle", "mount_at": "components/component", "attach_to": "components", }, { diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json index 620480675..fc03f43d4 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/_expected/needs_json/needs.json @@ -10,6 +10,8 @@ }, "needs": { "comp__modern_component": { + "bazel_target": "@@//src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component:component_sources", + "bazel_type": "filegroup", "belongs_to": [ "feat__modern_component" ], diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD index f6c96be2c..0da709a99 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD @@ -28,8 +28,12 @@ filegroup( # requirement links to the platform feature, so the component Needs target is # valid only when the parent module supplies that external Needs. docs_bundle( - name = "modern_component", + # Deliberately keep the Bazel target name independent from the Need ID. + # The component Need is selected explicitly even though this page contains + # feature, component, requirement, and tool Needs. + name = "docs_bundle", source_dir = ".", code_targets = [":component_sources"], + primary_need_id = "comp__modern_component", visibility = ["//visibility:public"], ) diff --git a/src/tests/docs_bzl/test_reference_integration.py b/src/tests/docs_bzl/test_reference_integration.py index 0edc99726..d46d59b9c 100644 --- a/src/tests/docs_bzl/test_reference_integration.py +++ b/src/tests/docs_bzl/test_reference_integration.py @@ -41,7 +41,7 @@ def test_linked_component_requires_parent_context(): run_scenario( "build", "reference_integration/modern_module/docs/components/component", - ":modern_component.__internal__.needs_local", + ":docs_bundle.__internal__.needs_local", ) assert "feat_req__platform__feature" in str(exc_info.value) @@ -104,20 +104,18 @@ def test_reference_integration_builds_with_platform_requirements(): @pytest.mark.bazel_cached -def test_bundle_metadata_is_added_to_the_matching_need(): +def test_bundle_metadata_is_added_to_the_explicit_primary_need(): """A component bundle contributes its direct Bazel target to its Need. - The fixture's bundle is named ``legacy_component`` and declares the local - Need ``tool_req__legacy_component``. The matching rule uses exactly this - ``__`` relationship, so the metadata must be added - to that Need rather than to another Need from the imported input data. + The fixture's bundle uses the generic Bazel target name ``docs_bundle`` but + explicitly selects ``tool_req__legacy_component``. This proves that the + association is independent of both the bundle name and imported Needs. """ result = run_scenario("build", "reference_integration", ":needs_json") assert result.artifacts is not None needs = load_needs(result.artifacts["needs.json"]) - # The ID is the expected match for the ``legacy_component`` bundle: - # ``tool_req`` is the Need type and ``legacy_component`` is the bundle name. + # The Need is selected by the bundle's explicit ``primary_need_id``. legacy_need = needs["tool_req__legacy_component"] assert isinstance(legacy_need, dict) assert ( From cc88e8c978fb54f99eb9720452216b486b87b939 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 24 Sep 2026 15:47:17 +0200 Subject: [PATCH 7/7] Use None for public primary need ID default --- bzl/bundle_rules.bzl | 11 +++++++++-- docs.bzl | 6 +++--- docs/reference/bazel_macros.rst | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 7e60ba7d0..fad6fffb4 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -522,7 +522,7 @@ def create_bundle( sourcelinks_json = None, source_dir = None, entry_doc = "index", - primary_need_id = "", + primary_need_id = None, data = [], code_targets = [], visibility = None, @@ -533,6 +533,13 @@ def create_bundle( because they use different runtime path and staging rules. """ parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles] + # The public macros use ``None`` to represent an omitted optional ID, but + # the underlying Bazel rule has a string attribute and the manifest schema + # keeps this field string-valued. Normalize at that boundary so callers do + # not need to know about the rule's empty-string sentinel. + normalized_primary_need_id = ( + primary_need_id if primary_need_id != None else "" + ) _docs_bundle( name = name, source_dir_globbed = source_dir_globbed, @@ -540,7 +547,7 @@ def create_bundle( sourcelinks_json = sourcelinks_json, source_dir = source_dir if source_dir != None else "", entry_doc = entry_doc, - primary_need_id = primary_need_id, + primary_need_id = normalized_primary_need_id, bundles = [bundle.bundle for bundle in parsed_bundles], bundle_mount_ats = [bundle.mount_at for bundle in parsed_bundles], bundle_attach_tos = [bundle.attach_to for bundle in parsed_bundles], diff --git a/docs.bzl b/docs.bzl index c572cca2f..fcf6fe519 100644 --- a/docs.bzl +++ b/docs.bzl @@ -226,7 +226,7 @@ def _declare_docs_bundle( entry_doc = "index", bundles = [], code_targets = [], - primary_need_id = "", + primary_need_id = None, visibility = None, **kwargs): """Declare the shared bundle target implementation. @@ -403,7 +403,7 @@ def docs_bundle( entry_doc = "index", bundles = [], code_targets = [], - primary_need_id = "", + primary_need_id = None, visibility = None, **kwargs): """Declare a reusable documentation bundle. @@ -507,7 +507,7 @@ def docs( deps = [], external_needs = [], code_targets = [], - primary_need_id = "", + primary_need_id = None, test_sources = [], known_good = None, metamodel = None, diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 9fd8b22d9..2de0cc4a9 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -171,7 +171,7 @@ site). visibility = ["//visibility:public"], ) -Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_doc = "index", bundles = [], code_targets = [], primary_need_id = "", visibility = None)``. +Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_doc = "index", bundles = [], code_targets = [], primary_need_id = None, visibility = None)``. - ``source_dir`` (string, optional) Directory holding the bundle's own doc sources. It is globbed the same way as