Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/concepts/mounts/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------

Expand Down
14 changes: 14 additions & 0 deletions src/extensions/docs/mounts_internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<mount>/**`` 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
Expand Down
168 changes: 157 additions & 11 deletions src/extensions/score_mounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}


Expand All @@ -194,13 +214,113 @@ 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 _mount_exclusions(
source_dir: Path,
source_mounts: list[tuple[MountSpec, Path]],
) -> 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.
"""
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)

for child_index in range(parent_index + 1, len(source_mounts)):
_, child_dir = source_mounts[child_index]

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(
config: Config,
exclusions: tuple[str, ...],
) -> 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.
"""
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:
Comment thread
AlexanderLanin marked this conversation as resolved.
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:
Expand All @@ -221,6 +341,27 @@ 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)
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;
# 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:
Expand All @@ -245,13 +386,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,
nested_exclusions[index],
)
runtime_mounts.append(_make_mount_entry(walk_dir, spec))
)

config.mounts = runtime_mounts

Expand Down
66 changes: 66 additions & 0 deletions src/extensions/score_mounts/tests/test_excludes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# *******************************************************************************
# 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
_mount_exclusions, # pyright: ignore[reportPrivateUsage] - white-box unit test
_nested_mount_pattern, # 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_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("parent"), source_dir / "parent"),
(_spec("child"), source_dir / "parent" / "child"),
(_spec("external"), tmp_path / "external"),
]

primary_exclusions, nested_exclusions = _mount_exclusions(
source_dir,
source_mounts,
)

assert primary_exclusions == ("parent/**", "parent/child/**")
assert nested_exclusions == (("child/**",), (), ())


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/**"]
2 changes: 2 additions & 0 deletions src/tests/docs_bzl/scenarios/reference_integration/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
}],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@
# 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
# *******************************************************************************

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",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
}],
Expand Down
Loading
Loading