From a492b2004c1497e298d1591c69b2c424b66057f8 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Tue, 1 Sep 2026 11:52:36 +0200 Subject: [PATCH 1/2] fix: exclude nested bundle mount roots --- docs/concepts/mounts/index.rst | 8 + src/extensions/docs/mounts_internals.rst | 14 ++ src/extensions/score_mounts/__init__.py | 171 ++++++++++++++++-- .../score_mounts/tests/test_excludes.py | 70 +++++++ .../scenarios/reference_integration/BUILD | 2 + .../reference_integration/legacy_module/BUILD | 4 +- .../docs/components/component}/BUILD | 9 +- .../docs/components/component}/index.rst | 0 .../reference_integration/modern_module/BUILD | 6 +- .../docs/components/component}/BUILD | 9 +- .../docs/components/component}/index.rst | 2 +- .../subdirectory_bundle/producer/BUILD | 4 +- .../producer/{ => docs}/embedded/BUILD | 2 +- .../{ => docs}/embedded/content/index.rst | 2 +- .../docs_bzl/test_reference_integration.py | 13 +- .../docs_bzl/test_subdirectory_bundle.py | 2 +- 16 files changed, 288 insertions(+), 30 deletions(-) create mode 100644 src/extensions/score_mounts/tests/test_excludes.py rename src/tests/docs_bzl/scenarios/reference_integration/{legacy_component => legacy_module/docs/components/component}/BUILD (68%) rename src/tests/docs_bzl/scenarios/reference_integration/{legacy_component/docs => legacy_module/docs/components/component}/index.rst (100%) rename src/tests/docs_bzl/scenarios/reference_integration/{modern_component => modern_module/docs/components/component}/BUILD (72%) rename src/tests/docs_bzl/scenarios/reference_integration/{modern_component/docs => modern_module/docs/components/component}/index.rst (91%) rename src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/{ => docs}/embedded/BUILD (90%) rename src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/{ => docs}/embedded/content/index.rst (91%) diff --git a/docs/concepts/mounts/index.rst b/docs/concepts/mounts/index.rst index 2e8761a8b..d627d4510 100644 --- a/docs/concepts/mounts/index.rst +++ b/docs/concepts/mounts/index.rst @@ -56,6 +56,14 @@ bundle would resolve to two different locations, the build fails instead of creating colliding docnames or Need IDs. Repeating the same placement is deduplicated. +Source directories are also ownership boundaries. When a mounted bundle lives +physically below the host's source directory, the host Sphinx walk excludes the +bundle subtree and the bundle mount discovers it. When a bundle contains a +child bundle below its own source directory, the parent mount excludes the +child subtree and the child mount discovers it. These are directory exclusions, +so new files remain discoverable during live preview without being discovered +twice. + External modules and Needs -------------------------- diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 2fbf7c3d5..37b516e62 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -39,6 +39,20 @@ Each manifest entry contains: * ``entry_doc`` — the canonical entry document declared by the source bundle. * ``external`` — whether the directory belongs to another Bazel module. +At ``config-inited``, ``score_mounts`` resolves all directory source mounts before +constructing ``config.mounts``. A mount below Sphinx's primary source directory +is added to the primary ``exclude_patterns`` as a relative ``/**`` pattern. +A mount below another mount is added to the parent entry's ``sphinx-mounts`` +``exclude`` patterns. This prevents a document from being discovered by both its +containing directory walk and its owning mount. Because the exclusions are +directory patterns rather than a snapshot of source files, newly created files +remain visible to live preview. + +Explicit ``docs_bundle(srcs = [...])`` entries remain in file-list mode and are +not included in these directory exclusions. They are intended for generated +sources outside the primary source tree; an explicitly mounted workspace file +below a walked root is a known limitation and would need exact-file exclusions. + The rule rejects conflicting final placements before Sphinx starts. A mount without ``attach_to`` is attached to the ``index`` document beside its ``mount_at``; ``attach_to`` overrides that target. The Python diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index a20653e71..5df14e8a7 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -15,11 +15,20 @@ 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 roots originate from Bazel; this extension resolves them for the +active execution context and derives structural directory exclusions. 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``. + +For directory mounts, the source-ownership invariant is that every document is +discovered exactly once: the primary Sphinx source tree owns files outside mounted +roots, and a directory mount owns its root except for nested directory mounts. The +exclusions below encode those boundaries as directory patterns so the ownership +remains correct when files are added later. Explicit file-list mounts remain in +file-list mode; they are intended for generated sources outside the primary source +tree, and nested workspace ``srcs`` are a known limitation of this logic. """ from __future__ import annotations @@ -162,13 +171,24 @@ def _canonical_mount_dir(walk_dir: Path, spec: MountSpec) -> Path: return walk_dir.resolve() -def _make_mount_entry(walk_dir: Path, spec: MountSpec) -> dict[str, object]: - """Build a mount entry dict from a canonical directory and spec.""" +def _make_mount_entry( + walk_dir: Path, + spec: MountSpec, + exclude: tuple[str, ...] = (), +) -> dict[str, object]: + """Build a ``sphinx_mounts`` directory entry. + + ``exclude`` contains paths relative to ``walk_dir``. ``sphinx_mounts`` applies + those patterns during its recursive walk, so an empty tuple means this mount + owns the whole directory and a pattern such as ``components/**`` delegates + that subtree to a nested mount. + """ return { "dir": str(_canonical_mount_dir(walk_dir, spec)), "mount_at": spec.mount_at, "attach_to": spec.attach_to, "entry_doc": spec.entry_doc, + "exclude": list(exclude), } @@ -194,13 +214,119 @@ def _configured_source_suffixes(config: Config) -> tuple[str, ...]: return tuple(configured) +def _nested_mount_pattern(parent_dir: Path, child_dir: Path) -> str | None: + """Map a strict physical descendant to a recursive relative glob. + + Mount directories have already been resolved before this helper is called, so + the comparison is about the directories Sphinx will physically walk rather + than their Bazel or manifest spellings. Equal and unrelated directories do + not create an ownership boundary and therefore return ``None``. + """ + try: + relative_dir = child_dir.relative_to(parent_dir) + except ValueError: + return None + if not relative_dir.parts: + return None + return f"{relative_dir.as_posix()}/**" + + +def _primary_mount_excludes( + source_dir: Path, + source_mounts: list[tuple[MountSpec, Path]], +) -> list[str]: + """Return primary-walk exclusions for mounts below the app source root. + + Without these patterns, Sphinx's normal walk of ``source_dir`` would also + discover documents that ``sphinx_mounts`` is about to register at the bundle's + ``mount_at`` location. The mounted directory must be the sole owner of those + documents. + """ + patterns: set[str] = set() + for _, mount_dir in source_mounts: + pattern = _nested_mount_pattern(source_dir, mount_dir) + if pattern is not None: + patterns.add(pattern) + return sorted(patterns) + + +def _nested_mount_excludes( + parent_index: int, + source_mounts: list[tuple[MountSpec, Path]], +) -> list[str]: + """Return all descendant mount roots excluded from one directory mount. + + The result includes direct and deeper descendants. Excluding every descendant + makes the ownership boundary independent of manifest order: each nested mount + receives its own documents, while the containing mount keeps the rest. + """ + _, parent_dir = source_mounts[parent_index] + patterns: set[str] = set() + for child_index, (_, child_dir) in enumerate(source_mounts): + if child_index == parent_index: + continue + pattern = _nested_mount_pattern(parent_dir, child_dir) + if pattern is not None: + patterns.add(pattern) + return sorted(patterns) + + +def _exclude_mounted_primary_sources( + app: Sphinx, + config: Config, + source_mounts: list[tuple[MountSpec, Path]], +) -> None: + """Hide mounted bundle roots from Sphinx's primary source discovery. + + The exclusion is based on directory ownership rather than the manifest's + current file list. This keeps newly created files visible to live preview + while ensuring a source file is discovered by either the host tree or its + owning bundle mount, never both. + """ + source_dir = Path(app.srcdir).resolve() + exclusions = _primary_mount_excludes(source_dir, source_mounts) + if exclusions: + # Preserve project-configured exclusions and append only the bundle roots + # that are physically inside the primary source tree. + config.exclude_patterns = [*config.exclude_patterns, *exclusions] + + +def _resolve_source_mounts( + manifest: MountsManifest, + ws_root: Path | None, + runfiles_dir: Path | None, +) -> list[tuple[MountSpec, Path]]: + """Resolve and validate the directory mounts used for ownership checks. + + Explicit source bundles are deliberately omitted: ``docs_bundle(srcs = [...])`` + owns a declared file list, not the directory containing those files, so it + must remain in ``sphinx_mounts`` file-list mode and must not create a directory + exclusion. ``srcs`` is intended for generated sources outside the primary + source tree; an explicitly mounted workspace file below a walked root remains + a known limitation because exact-file exclusions are not derived here. + """ + source_mounts: list[tuple[MountSpec, Path]] = [] + for spec in manifest.mounts: + if not spec.src_root or spec.files: + continue + walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) + if not walk_dir.is_dir(): + raise ValueError( + "score_mounts: resolved mount dir does not exist: " + f"{walk_dir} (mount_at={spec.mount_at})" + ) + source_mounts.append((spec, walk_dir.resolve())) + return source_mounts + + 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. + ``sphinx_mounts`` should walk, excludes nested mount roots from containing + walks, and writes the assembled list to ``config.mounts``. A missing or + empty manifest is a no-op. """ manifest = _read_manifest(config) if manifest is None or not manifest.mounts: @@ -221,6 +347,24 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # bazel-out/ and is NOT colocated with them, so src_root is resolved # against the exec root (the sphinx action's cwd), not the manifest. + # Directory mounts need to be resolved as a group before runtime entries are + # assembled. Only then can their physical roots be compared for nesting and + # can both Sphinx's primary walk and each parent mount be given exclusions. + source_mounts = _resolve_source_mounts(manifest, ws_root, runfiles_dir) + _exclude_mounted_primary_sources(app, config, source_mounts) + + # ``source_mounts`` omits pure-data and explicit file-list entries. Explicit + # ``srcs`` entries intentionally retain their existing file-list behavior; + # generated sources are expected to live outside the primary source tree. + # Map the remaining specs back to their prevalidated paths by object identity + # so the following loop can preserve manifest declaration order. ``MountSpec`` + # contains lists and is therefore not usable as a dictionary key, despite its + # frozen dataclass declaration. + source_mounts_by_id = { + id(spec): (index, walk_dir) + for index, (spec, walk_dir) in enumerate(source_mounts) + } + # Pure-data bundles have empty src_root; skip directory walk. runtime_mounts: list[dict[str, object]] = [] for spec in manifest.mounts: @@ -245,13 +389,18 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # resolved relative to the explicitly mounted document. runtime_mounts.append(_make_file_mount_entry(document_files, spec)) continue - walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) - if not walk_dir.is_dir(): - raise ValueError( - "score_mounts: resolved mount dir does not exist: " - f"{walk_dir} (mount_at={spec.mount_at})" + + # This directory was validated during the ownership pass above. Reuse its + # resolved spelling so the exclusion patterns and the runtime mount refer + # to exactly the same physical root. + index, walk_dir = source_mounts_by_id[id(spec)] + runtime_mounts.append( + _make_mount_entry( + walk_dir, + spec, + tuple(_nested_mount_excludes(index, source_mounts)), ) - runtime_mounts.append(_make_mount_entry(walk_dir, spec)) + ) config.mounts = runtime_mounts diff --git a/src/extensions/score_mounts/tests/test_excludes.py b/src/extensions/score_mounts/tests/test_excludes.py new file mode 100644 index 000000000..709bb1657 --- /dev/null +++ b/src/extensions/score_mounts/tests/test_excludes.py @@ -0,0 +1,70 @@ +# ******************************************************************************* +# 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 structural source-directory exclusions.""" + +from pathlib import Path + +from src.extensions.score_mounts import ( + _make_mount_entry, # pyright: ignore[reportPrivateUsage] - white-box unit test + _nested_mount_excludes, # pyright: ignore[reportPrivateUsage] - white-box unit test + _nested_mount_pattern, # pyright: ignore[reportPrivateUsage] - white-box unit test + _primary_mount_excludes, # pyright: ignore[reportPrivateUsage] - white-box unit test +) +from src.extensions.score_mounts._resolver import MountSpec + + +def _spec(mount_at: str) -> MountSpec: + """Create the minimal mount specification used by path tests.""" + return MountSpec(src_root="docs", runtime_path="docs", mount_at=mount_at) + + +def test_nested_mount_pattern_only_matches_descendants(tmp_path: Path) -> None: + """A mount excludes a child directory, not itself or a sibling.""" + parent = tmp_path / "parent" + + assert _nested_mount_pattern(parent, parent / "child") == "child/**" + assert _nested_mount_pattern(parent, parent) is None + assert _nested_mount_pattern(parent, tmp_path / "sibling") is None + + +def test_primary_mount_excludes_only_mounts_below_source(tmp_path: Path) -> None: + """Only physically nested mounts are hidden from primary discovery.""" + source_dir = tmp_path / "docs" + source_mounts = [ + (_spec("nested"), source_dir / "nested"), + (_spec("external"), tmp_path / "external"), + ] + + assert _primary_mount_excludes(source_dir, source_mounts) == ["nested/**"] + + +def test_parent_mount_excludes_nested_child_mount(tmp_path: Path) -> None: + """A parent directory mount skips a child that has its own mount.""" + source_mounts = [ + (_spec("parent"), tmp_path / "parent"), + (_spec("child"), tmp_path / "parent" / "child"), + (_spec("other"), tmp_path / "other"), + ] + + assert _nested_mount_excludes(0, source_mounts) == ["child/**"] + assert _nested_mount_excludes(1, source_mounts) == [] + + +def test_mount_entry_serializes_child_exclusions(tmp_path: Path) -> None: + """Structural exclusions are passed to sphinx-mounts.""" + tmp_path.mkdir(exist_ok=True) + (tmp_path / "index.rst").write_text("Index", encoding="utf-8") + + entry = _make_mount_entry(tmp_path, _spec("parent"), ("child/**",)) + + assert entry["exclude"] == ["child/**"] diff --git a/src/tests/docs_bzl/scenarios/reference_integration/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/BUILD index 6994a3bc1..f55f9d273 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/BUILD @@ -16,6 +16,8 @@ load("//:docs.bzl", "docs") # The reference integration combines platform feature requirements and two # software modules into one documentation site, just like the real S-CORE # integration. Each module's bundle contains its component documentation. +# The component packages live below the module's ``docs`` source directory, +# matching the feature-package layout used by the real S-CORE integration. # # One module and its component use the older ``docs(data = [...])`` API; the # other module and component use ``external_needs``. The integration mounts 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 21164ece2..7ab6b55b6 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/BUILD @@ -24,6 +24,8 @@ load("//:docs.bzl", "docs") # The reference integration is essential because it assembles the module's # documentation bundle with the rest of the platform documentation. The # module itself assembles its component documentation below ``components/``. +# The component package is physically below this module's ``docs`` source tree, +# so the module's primary Sphinx walk must leave ownership to the child mount. docs( project = "S-CORE Legacy Module", project_url = "https://example.invalid/score-legacy-module", @@ -33,7 +35,7 @@ docs( "@score_process_description//:needs_json", ], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/legacy_component:docs_bundle", + "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_component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD similarity index 68% rename from src/tests/docs_bzl/scenarios/reference_integration/legacy_component/BUILD rename to src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD index f78219370..416739582 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/legacy_component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/BUILD @@ -5,7 +5,7 @@ # 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 +# 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 @@ -13,11 +13,14 @@ load("//:docs.bzl", "docs") -# A component documentation bundle mounted by the legacy module. +# Keep the component as a complete documentation producer while placing its +# Bazel package below the module's source directory. This mirrors the feature +# package layout used by the S-CORE integration and exercises package-aware +# source ownership at runtime. docs( project = "S-CORE Legacy Component", project_url = "https://example.invalid/score-legacy-component", - source_dir = "docs", + source_dir = ".", data = [ "//src/tests/docs_bzl/scenarios/reference_integration/score_platform:needs_json", ], diff --git a/src/tests/docs_bzl/scenarios/reference_integration/legacy_component/docs/index.rst b/src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/index.rst similarity index 100% rename from src/tests/docs_bzl/scenarios/reference_integration/legacy_component/docs/index.rst rename to src/tests/docs_bzl/scenarios/reference_integration/legacy_module/docs/components/component/index.rst 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 e3915ebfa..cec0940f0 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/BUILD @@ -14,7 +14,9 @@ load("//:docs.bzl", "docs") # This module uses the current ``external_needs`` API to import platform -# feature requirements and mounts its component documentation. +# feature requirements and mounts its component documentation. The component +# package is nested below this module's ``docs`` source tree, so both the +# module's primary walk and its parent bundle mount must exclude that child. docs( project = "S-CORE Modern Module", project_url = "https://example.invalid/score-modern-module", @@ -24,7 +26,7 @@ docs( "@score_process_description//:needs_json", ], bundles = [{ - "bundle": "//src/tests/docs_bzl/scenarios/reference_integration/modern_component:docs_bundle", + "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_component/BUILD b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD similarity index 72% rename from src/tests/docs_bzl/scenarios/reference_integration/modern_component/BUILD rename to src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD index 6ad11f569..763f5846f 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_component/BUILD +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/BUILD @@ -5,7 +5,7 @@ # 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 +# 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 @@ -13,12 +13,13 @@ load("//:docs.bzl", "docs") -# This component uses the current ``external_needs`` API and is mounted by the -# modern module. +# This component is a nested documentation package, like the feature bundles +# in the S-CORE integration. Its parent module owns the surrounding source +# tree and mounts this bundle at the component placement. docs( project = "S-CORE Modern Component", project_url = "https://example.invalid/score-modern-component", - source_dir = "docs", + source_dir = ".", external_needs = [ "//src/tests/docs_bzl/scenarios/reference_integration/score_platform:needs_json", ], diff --git a/src/tests/docs_bzl/scenarios/reference_integration/modern_component/docs/index.rst b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst similarity index 91% rename from src/tests/docs_bzl/scenarios/reference_integration/modern_component/docs/index.rst rename to src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst index f8ede8979..773536ef4 100644 --- a/src/tests/docs_bzl/scenarios/reference_integration/modern_component/docs/index.rst +++ b/src/tests/docs_bzl/scenarios/reference_integration/modern_module/docs/components/component/index.rst @@ -6,7 +6,7 @@ # 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 + # 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 diff --git a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD index cee570dfb..a412bf165 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/BUILD @@ -13,7 +13,7 @@ load("//:docs.bzl", "docs") -# The embedded bundle lives in a subdirectory package alongside the docs source. +# The embedded bundle lives in a nested package below the docs source. # The parent docs() must mount it explicitly because it is a separate package. docs( source_dir = "docs", @@ -21,7 +21,7 @@ docs( 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 90% 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..995a8f657 100644 --- a/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/embedded/BUILD +++ b/src/tests/docs_bzl/scenarios/subdirectory_bundle/producer/docs/embedded/BUILD @@ -5,7 +5,7 @@ # 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 +# 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 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/content/index.rst similarity index 91% 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/content/index.rst index ffd7b2859..e82093b5d 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/content/index.rst @@ -6,7 +6,7 @@ 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 + 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 diff --git a/src/tests/docs_bzl/test_reference_integration.py b/src/tests/docs_bzl/test_reference_integration.py index 0b31075a4..7853f6420 100644 --- a/src/tests/docs_bzl/test_reference_integration.py +++ b/src/tests/docs_bzl/test_reference_integration.py @@ -33,8 +33,8 @@ def test_score_platform_publishes_feature_requirement(): [ "reference_integration/legacy_module", "reference_integration/modern_module", - "reference_integration/legacy_component", - "reference_integration/modern_component", + "reference_integration/legacy_module/docs/components/component", + "reference_integration/modern_module/docs/components/component", ], ) def test_module_and_component_needs_targets_build(scenario: str): @@ -42,8 +42,15 @@ def test_module_and_component_needs_targets_build(scenario: str): run_scenario("build", scenario, ":needs_json") +def test_nested_component_package_is_mounted_by_its_module(): + """A module run excludes its nested component from primary discovery.""" + result = run_scenario("run", "reference_integration/legacy_module", ":docs") + + assert (result.build_dir / "components" / "component" / "index.html").is_file() + + def test_reference_integration_builds_with_platform_requirements(): - """The integration mounts legacy and modern module documentation.""" + """The integration mounts modules and their nested component packages.""" result = run_scenario("run", "reference_integration", ":docs") html = (result.build_dir / "index.html").read_text(encoding="utf-8") diff --git a/src/tests/docs_bzl/test_subdirectory_bundle.py b/src/tests/docs_bzl/test_subdirectory_bundle.py index bf26263ab..6bbf6ba6a 100644 --- a/src/tests/docs_bzl/test_subdirectory_bundle.py +++ b/src/tests/docs_bzl/test_subdirectory_bundle.py @@ -10,7 +10,7 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Coverage for docs() with a docs_bundle in a nested Bazel package.""" +"""Coverage for bundles in nested Bazel packages and source directories.""" from src.tests.docs_bzl.helpers import load_needs, run_bazel, run_package From d1461a7bf9f4541fa9e752bc74686b182fac48bc Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 3 Sep 2026 09:52:17 +0200 Subject: [PATCH 2/2] refactor: precompute mount exclusions --- src/extensions/score_mounts/__init__.py | 73 +++++++++---------- .../score_mounts/tests/test_excludes.py | 30 ++++---- 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index 5df14e8a7..f00ec2f04 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -231,50 +231,46 @@ def _nested_mount_pattern(parent_dir: Path, child_dir: Path) -> str | None: return f"{relative_dir.as_posix()}/**" -def _primary_mount_excludes( +def _mount_exclusions( source_dir: Path, source_mounts: list[tuple[MountSpec, Path]], -) -> list[str]: - """Return primary-walk exclusions for mounts below the app source root. - - Without these patterns, Sphinx's normal walk of ``source_dir`` would also - discover documents that ``sphinx_mounts`` is about to register at the bundle's - ``mount_at`` location. The mounted directory must be the sole owner of those - documents. +) -> tuple[tuple[str, ...], tuple[tuple[str, ...], ...]]: + """Return primary and per-mount exclusions in one pairwise traversal. + + The primary source walk must exclude every directory mount below + ``source_dir``. Each directory mount must also exclude every nested directory + mount from its own walk. Computing both sets here avoids rescanning the full + mount list once for every parent mount. The two directions of each pair are + checked because either directory may be the descendant. """ - patterns: set[str] = set() - for _, mount_dir in source_mounts: - pattern = _nested_mount_pattern(source_dir, mount_dir) - if pattern is not None: - patterns.add(pattern) - return sorted(patterns) + primary_patterns: set[str] = set() + nested_patterns = [set[str]() for _ in source_mounts] + for parent_index, (_, parent_dir) in enumerate(source_mounts): + primary_pattern = _nested_mount_pattern(source_dir, parent_dir) + if primary_pattern is not None: + primary_patterns.add(primary_pattern) -def _nested_mount_excludes( - parent_index: int, - source_mounts: list[tuple[MountSpec, Path]], -) -> list[str]: - """Return all descendant mount roots excluded from one directory mount. + for child_index in range(parent_index + 1, len(source_mounts)): + _, child_dir = source_mounts[child_index] - The result includes direct and deeper descendants. Excluding every descendant - makes the ownership boundary independent of manifest order: each nested mount - receives its own documents, while the containing mount keeps the rest. - """ - _, parent_dir = source_mounts[parent_index] - patterns: set[str] = set() - for child_index, (_, child_dir) in enumerate(source_mounts): - if child_index == parent_index: - continue - pattern = _nested_mount_pattern(parent_dir, child_dir) - if pattern is not None: - patterns.add(pattern) - return sorted(patterns) + child_pattern = _nested_mount_pattern(parent_dir, child_dir) + if child_pattern is not None: + nested_patterns[parent_index].add(child_pattern) + + parent_pattern = _nested_mount_pattern(child_dir, parent_dir) + if parent_pattern is not None: + nested_patterns[child_index].add(parent_pattern) + + return ( + tuple(sorted(primary_patterns)), + tuple(tuple(sorted(patterns)) for patterns in nested_patterns), + ) def _exclude_mounted_primary_sources( - app: Sphinx, config: Config, - source_mounts: list[tuple[MountSpec, Path]], + exclusions: tuple[str, ...], ) -> None: """Hide mounted bundle roots from Sphinx's primary source discovery. @@ -283,8 +279,6 @@ def _exclude_mounted_primary_sources( while ensuring a source file is discovered by either the host tree or its owning bundle mount, never both. """ - source_dir = Path(app.srcdir).resolve() - exclusions = _primary_mount_excludes(source_dir, source_mounts) if exclusions: # Preserve project-configured exclusions and append only the bundle roots # that are physically inside the primary source tree. @@ -351,7 +345,10 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: # assembled. Only then can their physical roots be compared for nesting and # can both Sphinx's primary walk and each parent mount be given exclusions. source_mounts = _resolve_source_mounts(manifest, ws_root, runfiles_dir) - _exclude_mounted_primary_sources(app, config, source_mounts) + primary_exclusions, nested_exclusions = _mount_exclusions( + Path(app.srcdir).resolve(), source_mounts + ) + _exclude_mounted_primary_sources(config, primary_exclusions) # ``source_mounts`` omits pure-data and explicit file-list entries. Explicit # ``srcs`` entries intentionally retain their existing file-list behavior; @@ -398,7 +395,7 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: _make_mount_entry( walk_dir, spec, - tuple(_nested_mount_excludes(index, source_mounts)), + nested_exclusions[index], ) ) diff --git a/src/extensions/score_mounts/tests/test_excludes.py b/src/extensions/score_mounts/tests/test_excludes.py index 709bb1657..a255721aa 100644 --- a/src/extensions/score_mounts/tests/test_excludes.py +++ b/src/extensions/score_mounts/tests/test_excludes.py @@ -16,9 +16,8 @@ from src.extensions.score_mounts import ( _make_mount_entry, # pyright: ignore[reportPrivateUsage] - white-box unit test - _nested_mount_excludes, # pyright: ignore[reportPrivateUsage] - white-box unit test + _mount_exclusions, # pyright: ignore[reportPrivateUsage] - white-box unit test _nested_mount_pattern, # pyright: ignore[reportPrivateUsage] - white-box unit test - _primary_mount_excludes, # pyright: ignore[reportPrivateUsage] - white-box unit test ) from src.extensions.score_mounts._resolver import MountSpec @@ -37,27 +36,24 @@ def test_nested_mount_pattern_only_matches_descendants(tmp_path: Path) -> None: assert _nested_mount_pattern(parent, tmp_path / "sibling") is None -def test_primary_mount_excludes_only_mounts_below_source(tmp_path: Path) -> None: - """Only physically nested mounts are hidden from primary discovery.""" +def test_mount_exclusions_calculate_primary_and_nested_boundaries( + tmp_path: Path, +) -> None: + """Primary and nested ownership boundaries are calculated together.""" source_dir = tmp_path / "docs" source_mounts = [ - (_spec("nested"), source_dir / "nested"), + (_spec("parent"), source_dir / "parent"), + (_spec("child"), source_dir / "parent" / "child"), (_spec("external"), tmp_path / "external"), ] - assert _primary_mount_excludes(source_dir, source_mounts) == ["nested/**"] + primary_exclusions, nested_exclusions = _mount_exclusions( + source_dir, + source_mounts, + ) - -def test_parent_mount_excludes_nested_child_mount(tmp_path: Path) -> None: - """A parent directory mount skips a child that has its own mount.""" - source_mounts = [ - (_spec("parent"), tmp_path / "parent"), - (_spec("child"), tmp_path / "parent" / "child"), - (_spec("other"), tmp_path / "other"), - ] - - assert _nested_mount_excludes(0, source_mounts) == ["child/**"] - assert _nested_mount_excludes(1, source_mounts) == [] + assert primary_exclusions == ("parent/**", "parent/child/**") + assert nested_exclusions == (("child/**",), (), ()) def test_mount_entry_serializes_child_exclusions(tmp_path: Path) -> None: