diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index 54416c805..fad6fffb4 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 = None, data = [], code_targets = [], visibility = None, @@ -524,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, @@ -531,6 +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 = 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/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..fcf6fe519 100644 --- a/docs.bzl +++ b/docs.bzl @@ -226,6 +226,7 @@ def _declare_docs_bundle( entry_doc = "index", bundles = [], code_targets = [], + primary_need_id = None, 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 = None, 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 = None, 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..2de0cc4a9 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 = None, 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/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_metadata.py b/src/extensions/score_metamodel/bundle_metadata.py new file mode 100644 index 000000000..8b5014fde --- /dev/null +++ b/src/extensions/score_metamodel/bundle_metadata.py @@ -0,0 +1,272 @@ +# ******************************************************************************* +# 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 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 + +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_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. 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 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. + """ + + matched_need_ids: set[str] + changed_docnames: set[str] + + +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 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. + + 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 association. + + 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 no longer declares the Need. 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 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. + """ + 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 resolving explicit primary IDs. + + This runs at the start of ``env-updated`` from the current Sphinx + 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 + 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] = {} + 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_primary_bundle_metadata( + bundles: dict[str, BundleMetadata], + grouped: dict[str, list[NeedItem]], +) -> _BundleMetadataUpdate: + """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 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()): + if not bundle.primary_need_id: + logger.info( + f"bundle {bundle.label!r} ({bundle.name!r}) has direct code targets " + "but no primary_need_id; bundle metadata is not attached to a Need", + type="score_metamodel", + ) + continue + + primary_need = next( + ( + need + for need in grouped.get(bundle_label, []) + if str(need.get("id", "")) == bundle.primary_need_id + ), + None, + ) + 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( + primary_need, + _encode_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 = primary_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_unassociated_bundle_metadata( + needs: dict[str, NeedItem], + matched_need_ids: set[str], +) -> set[str]: + """Remove stale bundle metadata after all current bundles were processed. + + 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, + 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, 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_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 has a current + # association. + update.changed_docnames.update( + _clear_unassociated_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..790e6798e --- /dev/null +++ b/src/extensions/score_metamodel/tests/test_bundle_lifecycle.py @@ -0,0 +1,264 @@ +# ******************************************************************************* +# 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 with an explicitly selected primary Need.""" + return BundleMetadata( + label="//:memory", + name="unrelated_bundle_name", + primary_need_id="comp__memory", + code_targets=(BazelTarget(label="//:memory_core", type="cc_library"),), + ) + + +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._encode_target_values( # pyright: ignore[reportPrivateUsage] + bundle + ) + + assert values == { + "bazel_target": '["//:memory_core","//:memory_test"]', + "bazel_type": '["cc_library","cc_test"]', + } + + +def test_explicit_primary_need_is_updated_in_place_and_returns_affected_document( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The configured primary Need receives the bundle's direct target.""" + 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_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="another_unrelated_name", + primary_need_id="comp__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: + """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_primary_need_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing primary Need association loses stale bundle 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", + primary_need_id="comp__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_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/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/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/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/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/_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/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD index e26a1dfdf..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( + # 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 4cab24c9b..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 @@ -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..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" ], @@ -204,6 +206,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/component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD index e3d2d92cd..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( + # 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/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", diff --git a/src/tests/docs_bzl/test_reference_integration.py b/src/tests/docs_bzl/test_reference_integration.py index 87438baa7..d46d59b9c 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 @@ -101,3 +101,26 @@ 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_explicit_primary_need(): + """A component bundle contributes its direct Bazel target to its Need. + + 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 Need is selected by the bundle's explicit ``primary_need_id``. + 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"