diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index ab59f354c..1dcbc80df 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -61,7 +61,8 @@ load("@score_docs_as_code//:bzl/basics.bzl", "join_path") DocsBundleInfo = provider( doc = "A documentation bundle with its source and placement metadata.", fields = { - "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", + "entries": "Ordered entries, one per source directory, including its final documentation-tree location and direct source files.", + "own_source_entry": "This bundle's own source directory entry, or None for an aggregator.", "own_source_files": "This bundle's direct source files, excluding nested bundles.", "sourcelinks": "Source-code-link JSON files together with their owning repository.", "external_runfiles": "Documentation source files from external repositories needed in runfiles.", @@ -182,6 +183,9 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): generated files at a different mount. Giving every rebased entry the bundle's aggregate data would associate the same file with unrelated mounts, so the mounts resolver could select the wrong destination. + + The direct ``source_files`` list is also preserved through rebasing so the + manifest can reproduce the source package's allowlist at its final mount. """ is_bundle_root = not entry.mount_at if is_bundle_root: @@ -197,6 +201,7 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): entry_doc = entry.entry_doc, external = entry.external, repository = entry.repository, + source_files = entry.source_files, data = entry.data, ) @@ -244,11 +249,12 @@ def _docs_bundle_impl(ctx): own_source_files = [] own_external_runfiles = [] own_data = depset(direct = ctx.files.data) + own_source_entry = None if ctx.files.srcs: runtime_path = _bundle_runtime_path(ctx) external = runtime_path.startswith("../") - entries.append(struct( + own_source_entry = struct( runtime_path = runtime_path, # The execution root and runfiles tree spell external repositories # differently. Keep both locations so every public docs() target can @@ -259,8 +265,10 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = external, repository = ctx.label.workspace_name, + source_files = ctx.files.srcs, data = own_data, - )) + ) + entries.append(own_source_entry) own_source_files.extend(ctx.files.srcs) # Local sources are read directly from the workspace by ``bazel run``. # Only sources from external repositories must be staged in runfiles. @@ -276,6 +284,7 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = False, repository = ctx.label.workspace_name, + source_files = [], data = own_data, )) @@ -317,6 +326,7 @@ def _docs_bundle_impl(ctx): DefaultInfo(files = depset(transitive = [all_source_files, all_data])), DocsBundleInfo( entries = entries, + own_source_entry = own_source_entry, own_source_files = depset(direct = own_source_files), sourcelinks = sourcelinks, external_runfiles = external_runfiles, diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index dd42571eb..4a869406d 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -12,44 +12,102 @@ # ******************************************************************************* """ Conversion of documentation bundles from Bazel into mount metadata. + +The manifest records both the directories to mount and the exact documentation +files owned by each source bundle. The latter is needed because a source bundle +can live below another Bazel package, which is visible on disk but deliberately +excluded from the parent package's ``native.glob``. """ load("@score_docs_as_code//:bzl/bundle_rules.bzl", "DocsBundleInfo") +def _source_includes(entry): + """Return direct bundle files as Sphinx include patterns. + + ``sphinx-mounts`` applies these patterns relative to the mounted directory. + Include every file selected by the Bazel source glob: Sphinx will still + parse only files matching the consumer's ``source_suffix`` while assets + remain available to directives such as ``image`` and ``literalinclude``. + """ + source_root = entry.runtime_path.rstrip("/") + if source_root.endswith("/."): + source_root = source_root[:-2] + if source_root == ".": + source_root = "" + source_prefix = source_root + "/" if source_root else "" + includes = [] + for source_file in entry.source_files: + # ``source_files`` is populated by the ``source_dir``-scoped Bazel glob + # and preserved unchanged when an entry is rebased. This conversion + # therefore does not need to validate the source root again. + includes.append("/" + source_file.short_path[len(source_prefix):]) + if includes: + return sorted(includes) + + # An empty allowlist disables filtering in sphinx-mounts. Use an impossible + # path so an empty source entry cannot accidentally discover nearby docs. + return ["/__score_docs_as_code_no_direct_sources__"] + +def _entry_json(entry): + """Serialize one source or data entry for the mounts manifest.""" + return { + "src_root": entry.src_root, + "runtime_path": entry.runtime_path, + "mount_at": entry.mount_at, + "attach_to": entry.attach_to, + "entry_doc": entry.entry_doc, + "external": entry.external, + "repository": entry.repository, + "include": _source_includes(entry) if entry.src_root else [], + "data": [f.path for f in entry.data.to_list()], + } + def _mounts_manifest_impl(ctx): """Generate the canonical Sphinx mount manifest.""" bundle_info = ctx.attr.bundle[DocsBundleInfo] entries = bundle_info.entries + primary_entry = None + if ctx.attr.primary_bundle: + primary_entry = ctx.attr.primary_bundle[DocsBundleInfo].own_source_entry json_mounts = [] for entry in entries: - json_mounts.append({ - "src_root": entry.src_root, - "runtime_path": entry.runtime_path, - "mount_at": entry.mount_at, - "attach_to": entry.attach_to, - "entry_doc": entry.entry_doc, - "external": entry.external, - "repository": entry.repository, - "data": [f.path for f in entry.data.to_list()], - }) + json_mounts.append(_entry_json(entry)) out = ctx.actions.declare_file(ctx.label.name + ".json") - ctx.actions.write(out, json.encode({"mounts": json_mounts})) + manifest = {"mounts": json_mounts} + if primary_entry != None: + manifest["primary_source"] = _entry_json(primary_entry) + ctx.actions.write(out, json.encode(manifest)) return [DefaultInfo(files = depset([out]))] _create_mounts_manifest = rule( implementation = _mounts_manifest_impl, attrs = { "bundle": attr.label(providers = [DocsBundleInfo]), + "primary_bundle": attr.label( + default = None, + providers = [DocsBundleInfo], + doc = "Optional host bundle whose direct sources define the primary-source allowlist.", + ), }, doc = "Writes a Sphinx mount manifest from reusable documentation bundles.", ) -def create_mounts_manifest(name, bundle): - """Create a Sphinx mount manifest from reusable documentation bundles.""" +def create_mounts_manifest(name, bundle, primary_bundle = None): + """Create a Sphinx mount manifest from reusable documentation bundles. + + Args: + name: Manifest target name. + bundle: Bundle whose composed entries become ``mounts`` entries. + primary_bundle: Optional host bundle. Its own source entry is emitted as + ``primary_source`` so the runtime can exclude source files that are + physically below the host directory but outside the host package's + Bazel glob. + """ _create_mounts_manifest( name = name, bundle = bundle, + primary_bundle = primary_bundle, ) return ":" + name diff --git a/docs.bzl b/docs.bzl index 56eaa297a..7947fd840 100644 --- a/docs.bzl +++ b/docs.bzl @@ -316,10 +316,13 @@ def docs( visibility = ["//visibility:private"], ) + # The host bundle supplies the package-aware allowlist for its primary + # source tree; nested bundle sources remain mounted entries. mounts_manifest_label = [ create_mounts_manifest( name = "_mounts_manifest", bundle = mounts_bundle, + primary_bundle = ":docs_bundle", ), ] diff --git a/docs/concepts/mounts/index.rst b/docs/concepts/mounts/index.rst index 2e8761a8b..74f080b57 100644 --- a/docs/concepts/mounts/index.rst +++ b/docs/concepts/mounts/index.rst @@ -43,6 +43,12 @@ Bundles are read from their original source directories. Consequently, an in-repository bundle remains editable and IDE navigation reaches its real source files rather than generated copies. +Bazel package boundaries still determine ownership. A host ``docs()`` source +glob does not include documentation below a nested ``BUILD`` package. Such +content should be declared by a bundle in that package and composed through +``bundles``; the generated source allowlist prevents the live host directory +from rediscovering the same files as primary documentation. + Composition ----------- diff --git a/docs/how-to/bundles/index.rst b/docs/how-to/bundles/index.rst index 95969e352..6c10a4bf5 100644 --- a/docs/how-to/bundles/index.rst +++ b/docs/how-to/bundles/index.rst @@ -44,6 +44,11 @@ files are collected like those of ``docs()``; for example, ``entry_doc`` names the bundle-relative page used for navigation when the bundle is mounted. +Bazel package boundaries are part of source ownership. If a ``BUILD`` file +appears below ``source_dir``, the parent bundle does not glob that nested +package's files. Define those files in a nested ``docs_bundle`` and compose it +with ``bundles`` so the mounted tree contains them exactly once. + Mount a bundle in a project --------------------------- diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index d6f2ffc43..0e90d5a2c 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -72,6 +72,11 @@ Minimal example (root ``BUILD``) - ``source_dir`` (string, default: ``"docs"``) Path (relative to repository root) to your Sphinx source directory. This is the folder that contains the top-level ReST/markdown sources. A ``conf.py`` is optional. + Bazel package boundaries apply to this glob: a nested directory containing a + ``BUILD`` file is not included in the parent package's source set. Declare + documentation in such a nested package as a ``docs_bundle`` and mount it + through ``bundles``; the generated manifest keeps it from being discovered a + second time from the host directory. - ``project`` and ``project_url`` (strings, optional) Project name and canonical project URL. They are required when ``source_dir`` @@ -175,6 +180,9 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index" ``docs()`` (RST, Markdown, images, and the other doc file kinds). The ``source_dir`` itself is the mount root, so the files mount relative to it (so ``concept/index.rst`` with ``source_dir = "concept"`` becomes ``index.rst``). + Bazel package boundaries are respected. Sources below a nested ``BUILD`` + package are owned by that package rather than this bundle and must be exposed + through a nested bundle if they are part of the composed documentation. The bundle exposes those files as a Bazel depset (via the ``DocsBundleInfo`` provider) and records the ``source_dir`` path; sphinx-mounts walks that original directory directly — no copy is made. Leave it unset for a data-only bundle diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 2fbf7c3d5..7c613bf47 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -38,6 +38,20 @@ 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. +* ``repository`` — the Bazel repository that owns the entry; +* ``include`` — the exact files selected by the entry's direct Bazel source + glob, expressed as patterns relative to the mounted directory; and +* ``data`` — execroot-relative generated/supporting files resolved at the + entry's mount. + +The manifest may also contain a top-level ``primary_source`` entry. ``docs()`` +uses it for the host bundle's own source directory. At runtime, +``score_mounts`` adds every file matching Sphinx's configured source suffixes +that is present below that directory but absent from +``primary_source.include`` to Sphinx's ``exclude_patterns``. This mirrors +Bazel's package-aware source glob when a nested bundle package is physically +located below the host's source directory and prevents duplicate document +discovery. The rule rejects conflicting final placements before Sphinx starts. A mount without ``attach_to`` is attached to the ``index`` document beside its @@ -93,6 +107,11 @@ A ``source_dir`` bundle already has the mount-relative layout on disk. Materializing a second directory would duplicate its files, point navigation at a generated copy, and add a build action without changing the Sphinx input. +The direct-source allowlist in the manifest handles the one filtering case +needed by Bazel package boundaries without copying files: it limits discovery +to the files owned by the source bundle while leaving the original directory +available for mounted assets. + Reconsider materialization only if a future bundle cannot be represented by one existing directory, for example for a filtered file set, a custom ``strip_prefix``, or generated provider content. Prefer native filtering support diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index b03ca6d38..ff4bc24c2 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -15,11 +15,14 @@ Bridge extension: consume the mounts manifest authored by Bazel rules and feed it to ``sphinx_mounts``. -All mount paths originate from Bazel; this extension performs no path computation. It: +All mount metadata originates from Bazel; this extension only resolves the +provided paths for the active execution context. It: * sets ``config.mounts`` so ``sphinx_mounts`` can build the documentation; -``score_sync_toml`` reads the resulting ``config.mounts`` directly to write the -generated ``ubproject.toml``. +* lets ``score_sync_toml`` read the resulting ``config.mounts`` directly to + write the generated ``ubproject.toml``; and +* excludes source files that are present below the host source directory but + were not selected by the host package's Bazel glob. """ from __future__ import annotations @@ -162,22 +165,101 @@ def _canonical_mount_dir(walk_dir: Path, spec: MountSpec) -> Path: def _make_mount_entry(walk_dir: Path, spec: MountSpec) -> dict[str, object]: - """Build a mount entry dict from a canonical directory and spec.""" + """Build a mount entry dict from a canonical directory and spec. + + The ``include`` patterns are kept with the directory so + ``sphinx_mounts`` discovers exactly the files selected by Bazel. + """ return { "dir": str(_canonical_mount_dir(walk_dir, spec)), "mount_at": spec.mount_at, "attach_to": spec.attach_to, "entry_doc": spec.entry_doc, + "include": spec.include, } +def _source_suffixes(config: Config) -> tuple[str, ...]: + """Return the source suffixes configured for the current Sphinx project. + + Sphinx accepts a single suffix, a sequence of suffixes, or a mapping from + suffixes to parsers. The exclusion scan only needs the suffix keys. + """ + configured = config.source_suffix + if isinstance(configured, str): + return (configured,) + if isinstance(configured, dict): + return tuple(configured) + return tuple(configured or ()) + + +def _selected_source_paths(spec: MountSpec) -> set[str]: + """Normalize manifest include paths for comparison with relative paths.""" + return {path.lstrip("/") for path in spec.include} + + +def _is_source_file(path: Path, suffixes: tuple[str, ...]) -> bool: + """Return whether ``path`` is a file Sphinx may parse.""" + return path.is_file() and any(path.name.endswith(suffix) for suffix in suffixes) + + +def _unowned_source_paths( + source_dir: Path, + selected_paths: set[str], + suffixes: tuple[str, ...], +) -> list[str]: + """Find parseable files below ``source_dir`` that Bazel did not select.""" + unowned_paths = [] + for source_file in source_dir.rglob("*"): + if not _is_source_file(source_file, suffixes): + continue + relative_path = source_file.relative_to(source_dir).as_posix() + if relative_path not in selected_paths: + unowned_paths.append(relative_path) + return sorted(unowned_paths) + + +def _exclude_unowned_primary_sources( + app: Sphinx, + config: Config, + manifest: MountsManifest, + ws_root: Path | None, + runfiles_dir: Path | None, +) -> None: + """Keep Sphinx's primary source tree aligned with Bazel's direct glob. + + A nested Bazel package may leave its files visible in the live workspace + below ``app.srcdir`` even though the host package's ``native.glob`` omitted + them. Only files matching Sphinx's configured source suffixes need + exclusion; assets remain available below the source directory. + """ + spec = manifest.primary_source + if spec is None: + return + + source_dir = Path(app.srcdir).resolve() + primary_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir).resolve() + if primary_dir != source_dir: + return + + excluded = _unowned_source_paths( + source_dir, + _selected_source_paths(spec), + _source_suffixes(config), + ) + if excluded: + config.exclude_patterns = [*config.exclude_patterns, *excluded] + + def _on_config_inited(app: Sphinx, config: Config) -> None: """Translate the Bazel manifest into ``sphinx_mounts`` runtime config. Runs on Sphinx's ``config-inited`` event (before ``sphinx_mounts``, see the priority in ``setup``). For each mount it resolves the directory ``sphinx_mounts`` should walk and writes the assembled list to - ``config.mounts``. A missing or empty manifest is a no-op. + ``config.mounts``. When present, ``primary_source`` supplies the host + source allowlist before Sphinx discovers the primary tree. A missing or + empty manifest is a no-op. """ manifest = _read_manifest(config) if manifest is None or not manifest.mounts: @@ -186,6 +268,8 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: ws_root = find_ws_root() runfiles_dir = get_runfiles_dir() if ws_root is not None else None + _exclude_unowned_primary_sources(app, config, manifest, ws_root, runfiles_dir) + # In every context sphinx_mounts walks the bundle's original files (no copy is # made); only where those files are staged differs: # * external bundle: use its runfiles-relative location under ``bazel run`` diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index e820d8bac..1fe5751f4 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -29,6 +29,28 @@ @dataclass(frozen=True) class MountSpec: + """One Bazel-authored source or data mount in the runtime manifest. + + The path fields are intentionally kept as strings: Bazel chooses their + spelling for the current execution context, while the extension resolves + them only after it knows whether Sphinx is running from the workspace or a + sandbox. + + Attributes: + src_root: Execroot-relative source directory, or an empty string for a + data-only entry. + runtime_path: Runfiles-relative source directory used for external + repositories. + mount_at: Final documentation-tree placement. + attach_to: Host document receiving the entry document, if configured. + entry_doc: Bundle-relative navigation entry. + external: Whether the source belongs to another Bazel repository. + repository: Owning Bazel repository name. + include: Paths selected by the bundle's direct source glob, relative to + the mounted directory and formatted as Sphinx-mounts patterns. + data: Execroot-relative generated/supporting files owned by the entry. + """ + src_root: str runtime_path: str mount_at: str @@ -36,12 +58,54 @@ class MountSpec: entry_doc: str = "index" external: bool = False repository: str = "" + include: list[str] = field(default_factory=list) data: list[str] = field(default_factory=list) @dataclass(frozen=True) class MountsManifest: + """Parsed mounts manifest and optional host source allowlist. + + ``primary_source`` describes the host project's own source bundle. It is + separate from ``mounts`` because it is not mounted at a documentation-tree + placement; it tells the extension which files the host Bazel package owns + when the live source directory also contains nested packages. + """ + mounts: list[MountSpec] + primary_source: MountSpec | None = None + + +def _load_mount_spec(raw_entry: object) -> MountSpec: + """Validate and convert one mount manifest entry.""" + if not isinstance(raw_entry, dict): + raise ValueError(f"mounts manifest entry must be an object: {raw_entry!r}") + entry = cast("dict[str, object]", raw_entry) + if "src_root" not in entry or "mount_at" not in entry: + raise ValueError( + f"mounts manifest entry missing 'src_root'/'mount_at': {entry!r}" + ) + raw_data = entry.get("data", []) + if not isinstance(raw_data, list): + raise ValueError( + f"mounts manifest entry field 'data' must be a list: {raw_data!r}" + ) + raw_include = entry.get("include", []) + if not isinstance(raw_include, list): + raise ValueError( + f"mounts manifest entry field 'include' must be a list: {raw_include!r}" + ) + return MountSpec( + src_root=str(entry["src_root"]), + runtime_path=str(entry.get("runtime_path", "")), + mount_at=str(entry["mount_at"]), + attach_to=str(entry["attach_to"]) if entry.get("attach_to") else None, + entry_doc=str(entry["entry_doc"]) if entry.get("entry_doc") else "index", + external=bool(entry.get("external", False)), + repository=str(entry.get("repository", "")), + include=[str(path) for path in cast("list[object]", raw_include)], + data=[str(f) for f in cast("list[object]", raw_data)], + ) def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: @@ -63,33 +127,12 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: raise ValueError("mounts manifest field 'mounts' must be a list") typed_mounts_data = cast("list[object]", mounts_data) for raw_entry in typed_mounts_data: - if not isinstance(raw_entry, dict): - raise ValueError(f"mounts manifest entry must be an object: {raw_entry!r}") - entry = cast("dict[str, object]", raw_entry) - if "src_root" not in entry or "mount_at" not in entry: - raise ValueError( - f"mounts manifest entry missing 'src_root'/'mount_at': {entry!r}" - ) - raw_data = entry.get("data", []) - if not isinstance(raw_data, list): - raise ValueError( - f"mounts manifest entry field 'data' must be a list: {raw_data!r}" - ) - mounts.append( - MountSpec( - src_root=str(entry["src_root"]), - runtime_path=str(entry.get("runtime_path", "")), - mount_at=str(entry["mount_at"]), - attach_to=str(entry["attach_to"]) if entry.get("attach_to") else None, - entry_doc=str(entry["entry_doc"]) - if entry.get("entry_doc") - else "index", - external=bool(entry.get("external", False)), - repository=str(entry.get("repository", "")), - data=[str(f) for f in cast("list[object]", raw_data)], - ) - ) - return MountsManifest(mounts=mounts) + mounts.append(_load_mount_spec(raw_entry)) + raw_primary_source = data.get("primary_source") + primary_source = ( + _load_mount_spec(raw_primary_source) if raw_primary_source is not None else None + ) + return MountsManifest(mounts=mounts, primary_source=primary_source) def resolve_walk_dir( diff --git a/src/extensions/score_mounts/tests/test_init.py b/src/extensions/score_mounts/tests/test_init.py new file mode 100644 index 000000000..362b67741 --- /dev/null +++ b/src/extensions/score_mounts/tests/test_init.py @@ -0,0 +1,32 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for the primary-source filtering helpers.""" + +from pathlib import Path + +from src.extensions.score_mounts import _unowned_source_paths + + +def test_unowned_source_paths_only_returns_unselected_documents( + tmp_path: Path, +) -> None: + """Nested package documents are found while assets are left available.""" + (tmp_path / "index.rst").write_text("Index", encoding="utf-8") + (tmp_path / "nested.rst").write_text("Nested", encoding="utf-8") + (tmp_path / "diagram.svg").write_text("", encoding="utf-8") + + assert _unowned_source_paths( + tmp_path, + selected_paths={"index.rst"}, + suffixes=(".rst", ".md"), + ) == ["nested.rst"] diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index b9fe60e5b..5f9966d7e 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -71,6 +71,7 @@ def test_load_entry_with_attach_to_and_entry_doc(tmp_path: Path) -> None: "mount_at": "x", "attach_to": "internals/index", "entry_doc": "start", + "include": ["/start.rst"], } ], }, @@ -78,6 +79,31 @@ def test_load_entry_with_attach_to_and_entry_doc(tmp_path: Path) -> None: spec = load_mounts_manifest(str(manifest)).mounts[0] assert spec.attach_to == "internals/index" assert spec.entry_doc == "start" + assert spec.include == ["/start.rst"] + + +def test_load_primary_source(tmp_path: Path) -> None: + manifest = _write_manifest( + tmp_path, + { + "mounts": [], + "primary_source": { + "src_root": "docs", + "runtime_path": "docs", + "mount_at": "", + "include": ["/index.rst"], + }, + }, + ) + + primary_source = load_mounts_manifest(manifest).primary_source + + assert primary_source == MountSpec( + src_root="docs", + runtime_path="docs", + mount_at="", + include=["/index.rst"], + ) def test_external_mount_keeps_execroot_and_runfiles_locations(tmp_path: Path) -> None: diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD index cee570dfb..b6337d84b 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD @@ -13,15 +13,15 @@ load("//:docs.bzl", "docs") -# The embedded bundle lives in a subdirectory package alongside the docs source. -# The parent docs() must mount it explicitly because it is a separate package. +# The embedded bundle is a nested package below the parent source directory. +# Bazel owns its sources through the child bundle rather than the parent glob. docs( source_dir = "docs", project = "Subdirectory Bundle Producer", project_url = "https://example.invalid/subdirectory-bundle-producer", test_sources = ["src/tests/docs_bzl/scenarios/subdirectory_bundle/producer"], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded:docs_bundle", + "bundle": "//src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded:docs_bundle", "mount_at": "embedded", }], ) diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/BUILD b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/BUILD similarity index 96% rename from src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/BUILD rename to src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/BUILD index f19e1e6ff..ee3dca822 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/BUILD +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/BUILD @@ -15,6 +15,6 @@ load("//:docs.bzl", "docs_bundle") docs_bundle( name = "docs_bundle", - source_dir = "content", + source_dir = ".", visibility = ["//visibility:public"], ) diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/content/index.rst b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/index.rst similarity index 89% rename from src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/content/index.rst rename to src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/index.rst index ffd7b2859..d5d9263cc 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/content/index.rst +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/index.rst @@ -19,4 +19,4 @@ Embedded documentation :id: gd_req__embedded :version: 1 - The embedded documentation is supplied by a docs_bundle in a subdirectory. + The embedded documentation is supplied by a nested docs bundle.