diff --git a/bzl/bundle_rules.bzl b/bzl/bundle_rules.bzl index ab59f354c..737dc62f0 100644 --- a/bzl/bundle_rules.bzl +++ b/bzl/bundle_rules.bzl @@ -64,12 +64,10 @@ DocsBundleInfo = provider( "entries": "Ordered entries, one per source directory, including its final documentation-tree location.", "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.", - # Bundle-owned generated/supporting files. Both bundle data and - # docs(data = [...]) are build inputs; unlike host-owned docs data, - # these are resolved at this bundle's mount (for example, a generated - # index.rst). - "data": "Bundle-owned generated/supporting files resolved at the bundle's mount.", + "external_runfiles": "Documentation source files not read from the workspace at runtime.", + # Bundle-owned supporting/runtime files. Unlike host-owned docs data, + # these are resolved at this bundle's mount. + "data": "Bundle-owned supporting/runtime files resolved at the bundle's mount.", }, ) @@ -137,13 +135,68 @@ def _bundle_runtime_path(ctx): runfiles. Keep that spelling here; ``_bundle_execroot_path`` converts it to the corresponding ``external//...`` form for build actions. """ - source_file = ctx.files.srcs[0].short_path + # All files were globbed from this bundle's one source_dir, so the first + # file is representative for detecting an external-repository prefix. + source_file = ctx.files.source_dir_globbed[0].short_path external_prefix = "" if source_file.startswith("../"): path_parts = source_file.split("/") external_prefix = path_parts[0] + "/" + path_parts[1] + "/" return external_prefix + ctx.attr.strip_prefix.rstrip("/") +def _source_target_path(source_file): + """Return the path spelling used by the source-target staging action.""" + # Bazel marks workspace files with ``is_source``; generated outputs use + # the execroot path while workspace files use their runfiles short path. + return source_file.path if not source_file.is_source else source_file.short_path + +def _source_targets_runtime_path(files): + """Return the runtime directory shared by one set of source targets. + + Source targets are either workspace files or generated outputs. Their + paths use different Bazel spellings, while one manifest entry can carry + only one runtime root and one generated/source classification. The first + file therefore establishes the canonical representation and every later + file is validated against it. + """ + first_file = files[0] + first_path = _source_target_path(first_file) + first_is_source = first_file.is_source + separator = first_path.rfind("/") + # A root-package source has no parent component; ``.`` represents the + # workspace/execroot root so it can still be used as the shared directory. + runtime_path = first_path[:separator] if separator >= 0 else "." + for source_file in files[1:]: + # A single bundle entry cannot combine source roots from the workspace + # and bazel-out because they have different runtime resolution rules. + source_path = _source_target_path(source_file) + if source_file.is_source != first_is_source: + fail(("explicit bundle sources cannot mix workspace and generated files; " + + "found %r and %r") % (first_path, source_path)) + source_separator = source_path.rfind("/") + # Use the same ``.`` spelling for another root-package source. + source_root = source_path[:source_separator] if source_separator >= 0 else "." + if source_root != runtime_path: + fail(("explicit bundle sources must share one parent directory; " + + "found %r and %r") % (runtime_path, source_root)) + return runtime_path + +def _source_targets_relative_paths(files, runtime_path): + """Return each source target's path relative to the shared source root.""" + relative_paths = [] + for source_file in files: + source_path = _source_target_path(source_file) + # A root-package source has ``.`` as its shared parent, so its complete + # path is already relative to the staging tree. + if runtime_path == ".": + relative_paths.append(source_path) + continue + prefix = runtime_path + "/" + if not source_path.startswith(prefix): + fail("explicit bundle source %r is outside %r" % (source_path, runtime_path)) + relative_paths.append(source_path[len(prefix):]) + return relative_paths + def _bundle_execroot_path(runtime_path): """Return the execroot-relative spelling of an external runtime path.""" if runtime_path.startswith("../"): @@ -197,6 +250,11 @@ def _rebase_bundle_entry(entry, mount_at, attach_to): entry_doc = entry.entry_doc, external = entry.external, repository = entry.repository, + # Preserve whether the entry's source root comes from bazel-out when + # the entry is moved below a parent bundle's mount point. + generated = entry.generated, + # Preserve the explicit file allowlist when the entry is rebased. + files = entry.files, data = entry.data, ) @@ -245,7 +303,13 @@ def _docs_bundle_impl(ctx): own_external_runfiles = [] own_data = depset(direct = ctx.files.data) - if ctx.files.srcs: + # The macro validates this combination before creating the rule; retain + # the rule-level check for callers of the internal helper as well. + if ctx.files.source_dir_globbed and ctx.files.source_targets: + fail(("bundle %s cannot combine source_dir sources with explicit source " + + "targets") % ctx.label) + + if ctx.files.source_dir_globbed: runtime_path = _bundle_runtime_path(ctx) external = runtime_path.startswith("../") entries.append(struct( @@ -259,13 +323,48 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = external, repository = ctx.label.workspace_name, + # Directory-discovered sources are resolved from the workspace. + generated = False, + # Directory mounts discover all supported files below this root. + files = [], data = own_data, )) - own_source_files.extend(ctx.files.srcs) + own_source_files.extend(ctx.files.source_dir_globbed) # Local sources are read directly from the workspace by ``bazel run``. # Only sources from external repositories must be staged in runfiles. if external: - own_external_runfiles.extend(ctx.files.srcs) + own_external_runfiles.extend(ctx.files.source_dir_globbed) + elif ctx.files.source_targets: + # Keep explicit sources at their original paths; the manifest carries + # the declared relative file list so runtime discovery cannot include + # undeclared siblings from the shared parent directory. + runtime_path = _source_targets_runtime_path(ctx.files.source_targets) + source_files = _source_targets_relative_paths( + ctx.files.source_targets, + runtime_path, + ) + external = runtime_path.startswith("../") + entries.append(struct( + runtime_path = runtime_path, + src_root = _bundle_execroot_path(runtime_path), + mount_at = "", + attach_to = "", + entry_doc = ctx.attr.entry_doc, + external = external, + repository = ctx.label.workspace_name, + # Generated inputs need bazel-out-to-bazel-bin translation; source + # inputs resolve from their original workspace or runfiles paths. + generated = not ctx.files.source_targets[0].is_source, + # Runtime file-list mounting uses these paths relative to the + # original source root and therefore visits only declared files. + files = source_files, + data = own_data, + )) + own_source_files.extend(ctx.files.source_targets) + # Explicit artifacts outside the workspace source tree need to be + # staged for ``bazel run`` just like external source bundles. + if not ctx.files.source_targets[0].is_source or external: + own_external_runfiles.extend(ctx.files.source_targets) elif own_data: # Pure data bundle: create an entry so the data files appear in the manifest. entries.append(struct( @@ -276,6 +375,10 @@ def _docs_bundle_impl(ctx): entry_doc = ctx.attr.entry_doc, external = False, repository = ctx.label.workspace_name, + # Pure-data entries do not resolve a generated source root. + generated = False, + # Pure-data entries have no documentation source allowlist. + files = [], data = own_data, )) @@ -327,7 +430,8 @@ def _docs_bundle_impl(ctx): _docs_bundle = rule( implementation = _docs_bundle_impl, attrs = { - "srcs": attr.label_list(allow_files = True), + "source_dir_globbed": attr.label_list(allow_files = True), + "source_targets": attr.label_list(allow_files = True), "sourcelinks": attr.label_list(allow_files = True), "strip_prefix": attr.string(default = ""), "entry_doc": attr.string(default = "index"), @@ -339,12 +443,27 @@ _docs_bundle = rule( doc = "Internal rule that carries bundle files and their documentation-tree locations.", ) -def create_bundle(name, bundles, srcs = [], sourcelinks = [], strip_prefix = "", entry_doc = "index", data = [], visibility = None, **kwargs): - """Create a reusable documentation bundle from files and child declarations.""" +def create_bundle( + name, + bundles, + source_dir_globbed = [], + source_targets = [], + sourcelinks = [], + strip_prefix = "", + entry_doc = "index", + data = [], + visibility = None, + **kwargs): + """Create a bundle from directory-discovered files and source targets. + + ``source_dir_globbed`` and ``source_targets`` are separate internal inputs + because they use different runtime path and staging rules. + """ parsed_bundles = [_parse_bundle_declaration(declaration) for declaration in bundles] _docs_bundle( name = name, - srcs = srcs, + source_dir_globbed = source_dir_globbed, + source_targets = source_targets, sourcelinks = sourcelinks, strip_prefix = strip_prefix, entry_doc = entry_doc, diff --git a/bzl/mount_rules.bzl b/bzl/mount_rules.bzl index dd42571eb..4ec4e26d6 100644 --- a/bzl/mount_rules.bzl +++ b/bzl/mount_rules.bzl @@ -23,7 +23,7 @@ def _mounts_manifest_impl(ctx): json_mounts = [] for entry in entries: - json_mounts.append({ + mount = { "src_root": entry.src_root, "runtime_path": entry.runtime_path, "mount_at": entry.mount_at, @@ -31,8 +31,16 @@ def _mounts_manifest_impl(ctx): "entry_doc": entry.entry_doc, "external": entry.external, "repository": entry.repository, + # Tell the runtime resolver whether src_root is a generated output + # tree rather than a workspace or external-repository directory. + "generated": entry.generated, "data": [f.path for f in entry.data.to_list()], - }) + } + # Explicit source targets are mounted as a file allowlist. Directory + # bundles omit this key and retain the existing recursive behavior. + if entry.files: + mount["files"] = entry.files + json_mounts.append(mount) out = ctx.actions.declare_file(ctx.label.name + ".json") ctx.actions.write(out, json.encode({"mounts": json_mounts})) diff --git a/docs.bzl b/docs.bzl index 5e2d66fbc..3dd2e3858 100644 --- a/docs.bzl +++ b/docs.bzl @@ -101,7 +101,17 @@ _generated_conf = rule( }, ) -def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None, **kwargs): +def docs_bundle( + name, + source_dir = None, + srcs = [], + data = [], + entry_doc = "index", + bundles = [], + scan_code = [], + code_targets = [], + visibility = None, + **kwargs): """A docs bundle, optionally composed of others. Args: @@ -109,10 +119,16 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles source_dir: optional directory holding this bundle's own doc sources. It is globbed like `docs()` (same file kinds) and the contents are stored after stripping the `source_dir` prefix. Leave it unset for a pure aggregator. + srcs: Explicit documentation source files, including generated files. + Use this for a source-less bundle whose documentation is produced by a + build action. All files must share one parent directory so they can be + mounted as one bundle entry. data: Files owned by this bundle that are not discovered as documentation - sources. This includes generated RST, literalinclude inputs, and assets - that belong at the bundle's eventual mount location. Omitting - ``source_dir`` creates a bundle containing only these files. + sources. Use this for runtime/support files that belong at the bundle's + eventual mount location. ``docs(data = [...])`` is the corresponding + shorthand for supporting files in the project's root bundle. Both + forms make their files available to a build; only bundle data travels + with a mounted bundle. entry_doc: bundle-relative docname attached when this bundle is mounted. Defaults to `index`. bundles: nested bundles to compose, each a dict @@ -130,7 +146,15 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles **kwargs: Additional attributes forwarded to the underlying rule. """ - srcs = glob_doc_sources(source_dir) if source_dir != None else [] + if source_dir != None and srcs: + fail( + ("docs_bundle(%s): srcs cannot be combined with source_dir; " + + "put generated sources in a dedicated bundle") % name, + ) + + # Keep directory-discovered sources separate from explicit Bazel targets so + # each kind can retain its own runtime path and staging behavior. + source_dir_globbed = glob_doc_sources(source_dir) if source_dir != None else [] sourcelinks = [] if scan_code: print("WARNING: docs_bundle(%s) uses deprecated scan_code; use code_targets instead." % name) @@ -152,7 +176,8 @@ def docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles # The helper validates child declarations and creates the internal target. create_bundle( name = name, - srcs = srcs, + source_dir_globbed = source_dir_globbed, + source_targets = srcs, sourcelinks = sourcelinks, strip_prefix = strip_prefix, entry_doc = entry_doc, @@ -221,6 +246,9 @@ def docs( data: Additional files owned by this project's root ``:docs_bundle``. This is shorthand for declaring the files in that root bundle; mounted child content belongs in the child ``docs_bundle(data = [...])``. + For generated documentation in a mounted child, use that bundle's + explicit ``srcs`` instead; ``data`` remains for supporting/runtime + files. deps: Additional dependencies for the documentation build. external_needs: List of external needs targets to include in the documentation build. scan_code: Deprecated. Explicit source files or filegroups to scan for source @@ -245,7 +273,8 @@ def docs( ``docs(data = [...])`` owns files in the root ``:docs_bundle``. A child bundle uses the same ``data`` attribute and may omit ``source_dir`` when it - contains only generated or supporting files. + contains only supporting files. Use explicit ``srcs`` in a source-less + child bundle when its documentation is generated by a build action. """ # HINT: keep documentation sync docs/reference/bazel_macros.rst diff --git a/docs/how-to/bundles/examples.rst b/docs/how-to/bundles/examples.rst index 78d59e8d1..50020054c 100644 --- a/docs/how-to/bundles/examples.rst +++ b/docs/how-to/bundles/examples.rst @@ -51,12 +51,13 @@ If the parent is mounted at ``guides/example``, its page is rendered at ``guides/example/child/landing.html``. The consuming project can change ``guides/example`` without changing either bundle. -Mount generated documentation ------------------------------ +Mount generated bundle content +------------------------------ -When a build action produces documentation rather than a source-tree ``.rst`` -file, create a bundle without ``source_dir``. Its generated files are the -bundle's complete payload. You can generate and mount a page like this: +Use explicit ``srcs`` when a build action produces the documentation rather +than a source-tree ``.rst`` file. The source-less bundle has no ``source_dir``; +its generated files are the bundle's complete source payload. You can generate +and mount a page like this: .. code-block:: starlark @@ -68,10 +69,10 @@ bundle's complete payload. You can generate and mount a page like this: ===================' > $@""", ) - # Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree. + # Source bundle: the genrule output lives in ``bazel-out/``, not the tree. docs_bundle( name = "data_bundle", - data = [":generated_page"], + srcs = [":generated_page"], entry_doc = "index", visibility = ["//visibility:public"], ) @@ -89,8 +90,8 @@ See the `complete generated-data fixture on GitHub `_. The generated page is rendered at ``data_test/index.html`` and is added to the -consuming project's index page's toctree. It is mounted and navigated in -exactly the same way as a bundle with source files. +consuming project's index page's toctree. A generated source bundle is mounted +and navigated in exactly the same way as a bundle with workspace sources. Mount documentation from another module ---------------------------------------- diff --git a/docs/how-to/generated_docs.rst b/docs/how-to/generated_docs.rst index cfd2960a8..e5414cf25 100644 --- a/docs/how-to/generated_docs.rst +++ b/docs/how-to/generated_docs.rst @@ -23,16 +23,21 @@ with a ``docs_bundle`` and mount the bundle into your documentation tree. You find a `complete working example `_ in the :ref:`metamodel-reference`. -Supporting files belong to bundles ----------------------------------- +Which bundle attribute? +------------------------ + +For generated documentation, put the generated files in +``docs_bundle(srcs = [...])``. This follows Bazel's normal ``srcs`` semantics: +the files are inputs that Sphinx processes, even when they are generated by +another build action. -``docs(data = [...])`` adds files to the root ``:docs_bundle`` exposed by the -macro. A mounted documentation bundle declares its own files with -``docs_bundle(data = [...])``. In both cases the files are bundle payload and, -when mounted, are resolved below the bundle's ``mount_at`` path. +Use ``docs_bundle(data = [...])`` for runtime or supporting files that belong at +the bundle's mount but are not themselves documentation sources. The +``docs(data = [...])`` argument is shorthand for supporting files in the root +``:docs_bundle``; it is not a substitute for ``docs_bundle(srcs = [...])``. -For this how-to, generated documentation belongs in -``docs_bundle(data = [...])`` because it is mounted as a child bundle. +For this how-to, the rule is simple: generated documentation belongs in +``docs_bundle(srcs = [...])``. Step 1: Generate the RST Files ------------------------------ @@ -65,21 +70,22 @@ Verify: Step 2: Declare the Bundle -------------------------- -Wrap the generated files in a ``docs_bundle`` with the ``data`` attribute. -Do not use ``srcs`` — that is for handwritten sources in the source tree. +Wrap the generated files in a ``docs_bundle`` with the ``srcs`` attribute. +Explicit ``srcs`` may point to generated files; they are not limited to +handwritten sources. .. code-block:: starlark :caption: In your BUILD file docs_bundle( name = "design_bundle", - data = [":generate_design_rst"], + srcs = [":generate_design_rst"], ) -This bundle has no ``source_dir`` because all of its documentation is -generated. The generated ``index.rst`` becomes the bundle's entry page and -travels with the bundle when it is mounted. A bundle with both handwritten -sources and generated files can use ``source_dir`` and ``data`` together. +This is a source bundle without a ``source_dir``: all of its documentation is +generated. It is still a normal mountable bundle. The generated ``index.rst`` +becomes the bundle's entry page and travels with the bundle when it is mounted. +All explicit source files must share one parent directory. Verify: ``bazel build :design_bundle`` must succeed. diff --git a/docs/reference/bazel_macros.rst b/docs/reference/bazel_macros.rst index 275dcbbfc..9459c5c6f 100644 --- a/docs/reference/bazel_macros.rst +++ b/docs/reference/bazel_macros.rst @@ -25,18 +25,27 @@ See :doc:`commands ` for the targets/commands it creates. The macro must be called from the repository root package. -Supporting files ----------------- - -Documentation supporting files always belong to a ``docs_bundle``. The -``data`` argument of ``docs()`` is shorthand for adding files to the root -``:docs_bundle`` that the macro exposes. A mounted module puts its files in -its own ``docs_bundle(data = [...])``; those files travel with that bundle and -are resolved below its eventual ``mount_at`` path. - -A bundle with ``data`` but no ``source_dir`` is simply a bundle whose content -is generated or supporting files. It uses the same ownership and placement -mechanism as every other bundle. +Bundle content and supporting files +----------------------------------- + +There are two ways to add files to a bundle. The ``data`` argument of ``docs()`` +is shorthand for supporting files in the root ``:docs_bundle``: + +* ``docs_bundle(srcs = [...])`` puts documentation source files in a bundle. + The files may be generated outputs from another build action; they are + processed as documentation sources and resolved below the bundle's eventual + ``mount_at`` path. +* ``docs_bundle(data = [...])`` puts supporting or runtime files in a bundle + payload. Use this for files that belong at the mount but are not themselves + documentation sources. +* ``docs(data = [...])`` puts supporting files in the root ``:docs_bundle`` + exposed by ``docs()``. A mounted module puts its files in its own bundle; + these files travel with that bundle and are resolved below its eventual + ``mount_at`` path. + +If a file is mounted documentation, use ``docs_bundle(srcs = [...])``. Both +bundle attributes make files available to a build; they differ in whether the +files are processed as documentation sources or carried as supporting data. Minimal example (root ``BUILD``) -------------------------------- @@ -160,7 +169,7 @@ site). visibility = ["//visibility:public"], ) -Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None)``. +Signature: ``docs_bundle(name, source_dir = None, srcs = [], data = [], entry_doc = "index", bundles = [], scan_code = [], code_targets = [], visibility = None)``. - ``source_dir`` (string, optional) Directory holding the bundle's own doc sources. It is globbed the same way as @@ -169,16 +178,21 @@ Signature: ``docs_bundle(name, source_dir = None, data = [], entry_doc = "index" ``concept/index.rst`` with ``source_dir = "concept"`` becomes ``index.rst``). 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 - or for an aggregator that only composes other ``bundles``. + directory directly — no copy is made. Leave it unset for a bundle whose + sources are supplied explicitly or for an aggregator that only composes + other ``bundles``. + +- ``srcs`` (list of bazel labels, optional) + Explicit documentation source files, including generated outputs from a + build action. Use this for a source-less bundle whose documentation is + generated. All files must share one parent directory. It cannot be combined + with ``source_dir``. - ``data`` (list of bazel labels, optional) - Supporting or generated files owned by this bundle. These files are part of - the bundle payload and are available at the bundle's eventual mount path; - they are useful for generated documentation sources such as a generated - ``index.rst``. If ``source_dir`` is omitted and ``data`` contains the - bundle's deliverable, the result is a bundle containing only supporting - files. Use this attribute for every file that belongs with a mounted bundle. + Supporting or runtime files owned by this bundle. These files are part of the + bundle payload and are available at the bundle's eventual mount path, but are + not processed as the bundle's documentation sources. Use ``docs(data = [...])`` + for supporting files in the root bundle and this attribute for child bundles. - ``entry_doc`` (string, optional) Bundle-relative docname used as the canonical navigation entry. It defaults to diff --git a/src/extensions/score_metamodel/docs/BUILD b/src/extensions/score_metamodel/docs/BUILD index bb7621a9f..f1e75b7af 100644 --- a/src/extensions/score_metamodel/docs/BUILD +++ b/src/extensions/score_metamodel/docs/BUILD @@ -39,7 +39,8 @@ py_binary( docs_bundle( name = "metamodel", - data = [":generate_metamodel_rst"], + # The generated RST and Mermaid outputs form one explicit source tree. + srcs = [":generate_metamodel_rst"], entry_doc = "index", visibility = ["//visibility:public"], ) diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index b03ca6d38..a20653e71 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -35,6 +35,7 @@ MountsManifest, MountSpec, load_mounts_manifest, + resolve_source_files, resolve_walk_dir, ) from src.helper_lib import find_ws_root, get_runfiles_dir @@ -117,7 +118,7 @@ def _canonical_mount_dir(walk_dir: Path, spec: MountSpec) -> Path: files inside that directory can be symlinks to Bazel's repository cache, for example ``~/.cache/bazel/.../external/score_process_description+/process/index.rst``. - - for generated data bundles, the mount root may be the sandbox copy of a + - for generated bundle sources or data, the mount root may be the sandbox copy of a ``bazel-out`` directory, for example ``.../sandbox/.../execroot/_main/bazel-out/.../docs/generated``; generated files below it can resolve to the action execroot spelling, @@ -129,8 +130,8 @@ def _canonical_mount_dir(walk_dir: Path, spec: MountSpec) -> Path: ``.../sandbox/.../execroot/_main/score/socom/docs/index.rst`` ``→ /home/user/workspace/score/socom/docs/index.rst``. - This applies to all bundle types: external repositories, generated data - bundles, and in-tree (same-workspace) source bundles. Resolve one mounted + This applies to all bundle types: external repositories, generated bundle + sources or data, and in-tree (same-workspace) source bundles. Resolve one mounted source file first and walk back by its bundle-relative suffix to get the canonical root. @@ -171,6 +172,28 @@ def _make_mount_entry(walk_dir: Path, spec: MountSpec) -> dict[str, object]: } +def _make_file_mount_entry( + source_files: list[Path], spec: MountSpec +) -> dict[str, object]: + """Build a file-list mount entry from the original source files.""" + return { + "files": [str(source_file) for source_file in source_files], + "mount_at": spec.mount_at, + "attach_to": spec.attach_to, + "entry_doc": spec.entry_doc, + } + + +def _configured_source_suffixes(config: Config) -> tuple[str, ...]: + """Return the source suffixes configured for the current Sphinx build.""" + configured = config.source_suffix + # Sphinx accepts either a sequence of suffixes or a mapping from suffixes + # to parser names; both forms expose the suffixes during iteration. + if isinstance(configured, str): + return (configured,) + return tuple(configured) + + def _on_config_inited(app: Sphinx, config: Config) -> None: """Translate the Bazel manifest into ``sphinx_mounts`` runtime config. @@ -186,8 +209,9 @@ 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 - # In every context sphinx_mounts walks the bundle's original files (no copy is - # made); only where those files are staged differs: + # In every context sphinx_mounts reads the bundle's original files (no copy + # is made); directory mounts are walked while explicit source mounts use + # their declared file list. Only where those files are staged differs: # * external bundle: use its runfiles-relative location under ``bazel run`` # and its execroot-relative location in a sandboxed Bazel build. # * in-tree bundle under `bazel run`: use the live workspace source @@ -202,6 +226,25 @@ def _on_config_inited(app: Sphinx, config: Config) -> None: for spec in manifest.mounts: if not spec.src_root: continue + if spec.files: + # Explicit source bundles use sphinx-mounts' file-list mode so the + # original files are read directly without discovering siblings. + source_files = resolve_source_files(manifest, spec, ws_root, runfiles_dir) + source_suffixes = _configured_source_suffixes(config) + document_files = [ + source_file + for source_file in source_files + if any(source_file.name.endswith(suffix) for suffix in source_suffixes) + ] + if not document_files: + # An explicit bundle may contain only companion assets. Such + # assets remain available at their original paths, but there + # is no Sphinx document to register for this mount. + continue + # Companion assets stay in the original source directory and are + # 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( diff --git a/src/extensions/score_mounts/_resolver.py b/src/extensions/score_mounts/_resolver.py index e820d8bac..7705ddd27 100644 --- a/src/extensions/score_mounts/_resolver.py +++ b/src/extensions/score_mounts/_resolver.py @@ -29,6 +29,8 @@ @dataclass(frozen=True) class MountSpec: + """Describe one documentation mount and how its source root is resolved.""" + src_root: str runtime_path: str mount_at: str @@ -36,6 +38,12 @@ class MountSpec: entry_doc: str = "index" external: bool = False repository: str = "" + # Generated roots use bazel-bin under ``bazel run`` and bazel-out in a + # sandbox; source and external roots follow their normal path rules. + generated: bool = False + # Explicit source bundles provide paths relative to ``runtime_path`` so + # the mount can use the original files without recursively walking peers. + files: list[str] = field(default_factory=list) data: list[str] = field(default_factory=list) @@ -75,6 +83,11 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: raise ValueError( f"mounts manifest entry field 'data' must be a list: {raw_data!r}" ) + raw_files = entry.get("files", []) + if not isinstance(raw_files, list): + raise ValueError( + f"mounts manifest entry field 'files' must be a list: {raw_files!r}" + ) mounts.append( MountSpec( src_root=str(entry["src_root"]), @@ -86,6 +99,10 @@ def load_mounts_manifest(manifest_path: str | Path) -> MountsManifest: else "index", external=bool(entry.get("external", False)), repository=str(entry.get("repository", "")), + # Older manifests do not have this field and represent regular + # workspace or external-repository source roots. + generated=bool(entry.get("generated", False)), + files=[str(f) for f in cast("list[object]", raw_files)], data=[str(f) for f in cast("list[object]", raw_data)], ) ) @@ -98,7 +115,33 @@ def resolve_walk_dir( ws_root: Path | None, runfiles_dir: Path | None = None, ) -> Path: - """Resolve a mount directory for either ``bazel run`` or a sandbox build.""" + """Resolve a mount directory for either ``bazel run`` or a sandbox build. + + Generated source roots are recorded with their execroot-relative bazel-out + path, while ``bazel run`` exposes the same artifacts below ``bazel-bin`` in + the workspace. The ``generated`` flag selects that translation. + + For example, a generated ``bazel-out/k8-fastbuild/bin/pkg/docs`` root + resolves to ``/bazel-bin/pkg/docs`` under ``bazel run`` and to + ``/bazel-out/k8-fastbuild/bin/pkg/docs`` in a sandbox. A regular + workspace source resolves to ``/`` under ``bazel run`` + and ``/`` in a sandbox. + """ + if spec.generated: + if ws_root is not None: + # Generated source files are exposed through bazel-bin at runtime, + # while their manifest paths are execroot-relative bazel-out paths. + output_parts = spec.src_root.split("/") + if ( + # A generated file may be directly below the configuration's + # ``bin`` directory, so the source root itself can end there. + len(output_parts) >= 3 + and output_parts[0] == "bazel-out" + and output_parts[2] == "bin" + ): + return ws_root / "bazel-bin" / "/".join(output_parts[3:]) + return ws_root / spec.src_root + return Path.cwd() / spec.src_root if spec.external and ws_root is not None: if runfiles_dir is None: raise ValueError("external mounts under bazel run require RUNFILES_DIR") @@ -109,3 +152,28 @@ def resolve_walk_dir( if ws_root is not None: return ws_root / spec.src_root return Path.cwd() / spec.src_root + + +def resolve_source_files( + manifest: MountsManifest, + spec: MountSpec, + ws_root: Path | None, + runfiles_dir: Path | None = None, +) -> list[Path]: + """Resolve an explicit source allowlist below its original parent. + + ``src_root`` uses the same context-dependent resolution as directory + mounts. The manifest's relative file names then identify only the Bazel + artifacts declared by ``docs_bundle(srcs = [...])``. + """ + walk_dir = resolve_walk_dir(manifest, spec, ws_root, runfiles_dir) + resolved_files = [] + for relative_path in spec.files: + source_file = walk_dir / relative_path + if not source_file.is_file(): + raise ValueError( + "score_mounts: resolved source file does not exist: " + f"{source_file} (mount_at={spec.mount_at})" + ) + resolved_files.append(source_file) + return resolved_files diff --git a/src/extensions/score_mounts/tests/test_resolver.py b/src/extensions/score_mounts/tests/test_resolver.py index b9fe60e5b..326942fc3 100644 --- a/src/extensions/score_mounts/tests/test_resolver.py +++ b/src/extensions/score_mounts/tests/test_resolver.py @@ -13,9 +13,8 @@ """Unit tests for the mounts manifest loader (``_resolver``). These cover the pure parsing layer only: reading the JSON manifest into -``MountSpec`` objects, applying defaults, and rejecting malformed -input. Context-dependent path resolution (runfiles vs. exec root) lives in the -extension's ``__init__`` and is exercised via the consumer tests instead.""" +``MountSpec`` objects, applying defaults, rejecting malformed input, and +resolving source roots in runfiles versus an exec root.""" import json from pathlib import Path @@ -25,6 +24,7 @@ from src.extensions.score_mounts._resolver import ( MountSpec, load_mounts_manifest, + resolve_source_files, resolve_walk_dir, ) @@ -164,3 +164,109 @@ def test_external_mount_uses_runfiles_root_under_bazel_run(tmp_path: Path) -> No tmp_path / "workspace", tmp_path, ) == (tmp_path / "score_process_description+" / "docs_as_mount") + + +def test_generated_source_mount_uses_bazel_bin_under_bazel_run(tmp_path: Path) -> None: + """Translate an execroot-relative generated source to workspace bazel-bin.""" + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "bazel-out/k8-fastbuild/bin/pkg/generated", + "runtime_path": "bazel-out/k8-fastbuild/bin/pkg/generated", + "mount_at": "generated", + "generated": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert ( + resolve_walk_dir( + load_mounts_manifest(manifest), + spec, + tmp_path / "workspace", + tmp_path / "workspace" / "docs.runfiles", + ) + == tmp_path / "workspace" / "bazel-bin" / "pkg" / "generated" + ) + + +def test_generated_root_source_mount_uses_bazel_bin_under_bazel_run( + tmp_path: Path, +) -> None: + """Translate a generated root-level source to the bazel-bin directory.""" + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "bazel-out/k8-fastbuild/bin", + "runtime_path": "bazel-out/k8-fastbuild/bin", + "mount_at": "generated", + "generated": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert ( + resolve_walk_dir(load_mounts_manifest(manifest), spec, tmp_path / "workspace") + == tmp_path / "workspace" / "bazel-bin" + ) + + +def test_explicit_source_files_resolve_below_original_root(tmp_path: Path) -> None: + """Resolve an explicit file allowlist without copying its source files.""" + source_root = tmp_path / "workspace" / "docs" + source_root.mkdir(parents=True) + (source_root / "index.rst").write_text("Index", encoding="utf-8") + (source_root / "guide.rst").write_text("Guide", encoding="utf-8") + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "docs", + "runtime_path": "docs", + "mount_at": "generated", + "files": ["index.rst", "guide.rst"], + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert resolve_source_files( + load_mounts_manifest(manifest), + spec, + tmp_path / "workspace", + ) == [source_root / "index.rst", source_root / "guide.rst"] + + +def test_generated_source_mount_uses_execroot_in_sandbox( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keep the generated source's execroot path inside a sandbox.""" + monkeypatch.chdir(tmp_path) + manifest = _write_manifest( + tmp_path, + { + "mounts": [ + { + "src_root": "bazel-out/k8-fastbuild/bin/pkg/generated", + "runtime_path": "bazel-out/k8-fastbuild/bin/pkg/generated", + "mount_at": "generated", + "generated": True, + } + ] + }, + ) + spec = load_mounts_manifest(manifest).mounts[0] + + assert resolve_walk_dir(load_mounts_manifest(manifest), spec, None) == ( + tmp_path / "bazel-out" / "k8-fastbuild" / "bin" / "pkg" / "generated" + ) diff --git a/src/extensions/score_sync_toml/_mounts.py b/src/extensions/score_sync_toml/_mounts.py index d84cf3412..ab6ae9253 100644 --- a/src/extensions/score_sync_toml/_mounts.py +++ b/src/extensions/score_sync_toml/_mounts.py @@ -20,38 +20,53 @@ def _toml_string(value: str) -> str: return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' -def _toml_dir(entry: dict[str, Any]) -> str: - """Derive a stable TOML path from a resolved ``config.mounts`` entry.""" - walk_dir = Path(entry["dir"]).resolve() +def _toml_path(path: Path) -> str: + """Derive a stable TOML path from a resolved runtime path.""" + resolved_path = path.resolve() git_root = find_git_root() if git_root is not None: try: - return str(walk_dir.relative_to(git_root)) + return str(resolved_path.relative_to(git_root)) except ValueError: pass try: - external_path = walk_dir.relative_to(get_runfiles_dir()) + external_path = resolved_path.relative_to(get_runfiles_dir()) except ValueError: - return str(walk_dir) + return str(resolved_path) if external_path.parts[0] == "_main": return str(Path(*external_path.parts[1:])) return "bazel-bin/external/" + str(external_path) +def _toml_dir(entry: dict[str, Any]) -> str: + """Derive a stable TOML directory from a resolved mount entry.""" + return _toml_path(Path(entry["dir"])) + + def materialize_mounts(entries: list[dict[str, Any]]) -> Path | None: """Write resolved mounts as a temporary, Git-root-relative TOML merge file.""" if not entries: return None lines: list[str] = [] for entry in entries: + source_files = entry.get("files", []) lines.extend( [ "[[mounts]]", - f"dir = {_toml_string(_toml_dir(entry))}", - f"mount_at = {_toml_string(entry['mount_at'])}", ] ) + if source_files: + # Preserve explicit source mounts as a file allowlist in the + # generated TOML instead of widening them back to a directory. + files = ", ".join( + _toml_string(_toml_path(Path(source_file))) + for source_file in source_files + ) + lines.append(f"files = [{files}]") + else: + lines.append(f"dir = {_toml_string(_toml_dir(entry))}") + lines.append(f"mount_at = {_toml_string(entry['mount_at'])}") if entry.get("attach_to"): lines.append(f"attach_to = {_toml_string(entry['attach_to'])}") if entry.get("entry_doc", "index") != "index": diff --git a/src/extensions/score_sync_toml/test_mounts.py b/src/extensions/score_sync_toml/test_mounts.py index 52a299af6..d718b0b8a 100644 --- a/src/extensions/score_sync_toml/test_mounts.py +++ b/src/extensions/score_sync_toml/test_mounts.py @@ -72,6 +72,34 @@ def test_materialize_mounts_maps_external_runfiles_path_to_bazel_bin( ) +def test_materialize_mounts_preserves_explicit_source_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Serialize explicit mounts as files instead of widening them to dirs.""" + git_root = tmp_path / "workspace" + git_root.mkdir() + monkeypatch.setattr(_mounts, "find_git_root", lambda: git_root) + + fragment = materialize_mounts( + [ + { + "files": [ + str(git_root / "generated" / "index.rst"), + str(git_root / "generated" / "guide.rst"), + ], + "mount_at": "generated", + } + ] + ) + + assert fragment is not None + assert fragment.read_text(encoding="utf-8") == ( + "[[mounts]]\n" + 'files = ["generated/index.rst", "generated/guide.rst"]\n' + 'mount_at = "generated"\n' + ) + + def test_setup_skips_toml_sync_without_git_worktree( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD index c03222455..f796ba5e1 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/BUILD @@ -11,7 +11,7 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# This fixture exercises the public docs() macro with a data bundle whose +# This fixture exercises the public docs() macro with a source bundle whose # only content is a genrule output living in ``bazel-out/.../bin/``. That # output must reach the runfiles of ``:docs`` (via _external_docs_runfiles) # and be resolved by ``score_mounts`` at ``bazel run`` time. See PR #686. @@ -25,15 +25,40 @@ load("//:docs.bzl", "docs", "docs_bundle") genrule( name = "generated_page", srcs = [], - outs = ["generated/index.rst"], - cmd = """echo 'Generated Data Page -===================' > $@""", + outs = [ + "generated/index.rst", + "generated/generated_data.mmd", + ], + # The RST and Mermaid file are both declared sources, but only the RST + # should be registered as a Sphinx document. The Mermaid file is a + # companion asset that must remain readable next to the original RST. + cmd = """cat > $(location generated/index.rst) <<'EOF' +Generated Data Page +=================== + +.. mermaid:: generated_data.mmd + :name: generated-data-diagram +EOF +cat > $(location generated/generated_data.mmd) <<'EOF' +classDiagram + class GeneratedData +EOF""", ) -# Pure-data bundle: the genrule output lives in ``bazel-out/``, not the tree. +# Source bundle: the genrule output lives in ``bazel-out/``, not the tree. docs_bundle( name = "data_bundle", - data = [":generated_page"], + srcs = [":generated_page"], + entry_doc = "index", + visibility = ["//visibility:public"], +) + +# This source bundle deliberately declares only one file from ``isolated/``. +# The sibling below must not become part of the mounted documentation merely +# because it shares the source file's original parent directory. +docs_bundle( + name = "isolated_source_bundle", + srcs = ["isolated/index.rst"], entry_doc = "index", visibility = ["//visibility:public"], ) @@ -44,7 +69,33 @@ docs( "bundle": ":data_bundle", "mount_at": "data_test", "attach_to": "index", + }, { + "bundle": ":legacy_data_bundle", + "mount_at": "legacy_data_test", + "attach_to": "index", + }, { + "bundle": ":isolated_source_bundle", + "mount_at": "isolated_test", + "attach_to": "index", }], ) # END docs-bundle-howto: generated-data + +# Legacy compatibility: generated RST files declared through ``data`` must +# remain mountable while callers migrate to the explicit ``srcs`` attribute. +genrule( + name = "legacy_generated_page", + srcs = [], + outs = ["legacy_generated/index.rst"], + cmd = """echo 'Legacy Data Page +==================' > $@""", +) + +# Legacy compatibility bundle: ``data`` still reaches the Sphinx mount walker. +docs_bundle( + name = "legacy_data_bundle", + data = [":legacy_generated_page"], + entry_doc = "index", + visibility = ["//visibility:public"], +) diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst index 3259facbe..d97f68a41 100644 --- a/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/docs/index.rst @@ -17,3 +17,5 @@ Host page with generated bundle data The host project can mount a pure-data bundle and include its generated page in the documentation navigation. + +The explicitly declared source bundle also mounts only its declared page. diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst new file mode 100644 index 000000000..6af1abff7 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/index.rst @@ -0,0 +1,18 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Isolated Declared Page +====================== + +This page is explicitly declared in the bundle. diff --git a/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst new file mode 100644 index 000000000..76fe55768 --- /dev/null +++ b/src/tests/docs_bzl/scenarios/data_files_runfiles/isolated/undeclared.rst @@ -0,0 +1,18 @@ +.. + # ******************************************************************************* + # 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 + # ******************************************************************************* + +Undeclared Sibling Page +======================= + +This page must not be mounted by the explicit source bundle. diff --git a/src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative b/src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative new file mode 100644 index 000000000..f55926aee --- /dev/null +++ b/src/tests/docs_bzl/scenarios/invalid_source_combination/BUILD.negative @@ -0,0 +1,22 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +load("//:docs.bzl", "docs_bundle") + +# This BUILD fixture intentionally fails while the docs_bundle() macro is +# evaluated; the test copies it to BUILD only for the expected-error check. +docs_bundle( + name = "bad_source_and_srcs", + source_dir = "child", + srcs = ["child/a.rst"], +) diff --git a/src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst b/src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst new file mode 100644 index 000000000..63153274e --- /dev/null +++ b/src/tests/docs_bzl/scenarios/invalid_source_combination/child/a.rst @@ -0,0 +1,15 @@ +.. ******************************************************************************* + 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 + ******************************************************************************* + +Invalid source combination fixture +=================================== diff --git a/src/tests/docs_bzl/test_data_files_runfiles.py b/src/tests/docs_bzl/test_data_files_runfiles.py index 872a0cdeb..e770535aa 100644 --- a/src/tests/docs_bzl/test_data_files_runfiles.py +++ b/src/tests/docs_bzl/test_data_files_runfiles.py @@ -11,21 +11,42 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -"""Verify that genrule-generated RST files are reachable at ``bazel run`` time. +"""Verify that genrule-generated RST sources are reachable at ``bazel run`` time. -The ``data`` attribute of ``docs_bundle`` carries genrule outputs that live -in ``bazel-out/.../bin/``. The fix in ``_external_docs_runfiles_impl`` stages -them into the runfiles of ``:docs``; ``score_mounts`` then resolves the -execroot-relative paths against ``/bazel-bin`` and mounts them. This -end-to-end test fails if either half of that chain regresses.""" +The ``srcs`` attribute of ``docs_bundle`` can carry genrule outputs that live +in ``bazel-out/.../bin/``. The bundle stages them into the runfiles of +``:docs``; ``score_mounts`` then resolves the generated source root through +``/bazel-bin`` and mounts it. This end-to-end test fails if either half +of that chain regresses.""" from src.tests.docs_bzl.helpers import run_scenario -def test_data_files_reachable_at_runtime(): - """Genrule output in a docs_bundle data dep must be resolved by Sphinx.""" +def test_generated_source_files_reachable_at_runtime(): + """Genrule output in a docs_bundle src dep must be resolved by Sphinx.""" result = run_scenario("run", "data_files_runfiles", ":docs") generated_html = result.build_dir / "data_test" / "index.html" assert "Generated Data Page" in generated_html.read_text(encoding="utf-8") + assert "generated-data-diagram" in generated_html.read_text(encoding="utf-8") + + +def test_legacy_generated_data_files_remain_reachable_at_runtime(): + """Legacy generated RST files declared through ``data`` still work.""" + result = run_scenario("run", "data_files_runfiles", ":docs") + + legacy_html = result.build_dir / "legacy_data_test" / "index.html" + + assert "Legacy Data Page" in legacy_html.read_text(encoding="utf-8") + + +def test_explicit_source_bundle_excludes_undeclared_siblings(): + """Explicit source bundles must not recursively mount undeclared files.""" + result = run_scenario("run", "data_files_runfiles", ":docs") + + declared_html = result.build_dir / "isolated_test" / "index.html" + undeclared_html = result.build_dir / "isolated_test" / "undeclared.html" + + assert "Isolated Declared Page" in declared_html.read_text(encoding="utf-8") + assert not undeclared_html.exists() diff --git a/src/tests/docs_bzl/test_invalid_bundle_placements.py b/src/tests/docs_bzl/test_invalid_bundle_placements.py index 2c59249b9..4af200f89 100644 --- a/src/tests/docs_bzl/test_invalid_bundle_placements.py +++ b/src/tests/docs_bzl/test_invalid_bundle_placements.py @@ -23,7 +23,7 @@ # ******************************************************************************* """Invalid docs_bundle() placement scenario.""" -from src.tests.docs_bzl.helpers import run_scenario +from src.tests.docs_bzl.helpers import repo_root, run_scenario def test_invalid_bundle_placements_are_rejected_during_analysis(): @@ -31,3 +31,29 @@ def test_invalid_bundle_placements_are_rejected_during_analysis(): run_scenario( "build", "invalid_bundle_placements", ":bad_attach_to", expect_error=True ) + + +def test_source_dir_and_srcs_are_rejected_during_loading(): + scenario_dir = ( + repo_root() / "src/tests/docs_bzl/scenarios/invalid_source_combination" + ) + build_file = scenario_dir / "BUILD" + fixture = scenario_dir / "BUILD.negative" + # Keep this load-time failure fixture hidden from recursive Bazel targets. + assert not build_file.exists(), ( + "negative test package must not be discovered by //..." + ) + + # Install the invalid BUILD file only while the subprocess test exercises it. + build_file.write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + try: + result = run_scenario( + "build", + "invalid_source_combination", + ":bad_source_and_srcs", + expect_error=True, + ) + finally: + build_file.unlink() + + assert "srcs cannot be combined with source_dir" in result.stderr