diff --git a/docs/conf.py b/docs/conf.py
index 0255915c2..48385fcf2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,3 +18,7 @@
extensions = [
"score_sphinx_bundle",
]
+
+score_any_folder_mapping = {
+ "../src/extensions/docs": "internals/extensions",
+}
diff --git a/docs/how-to/any_folder.rst b/docs/how-to/any_folder.rst
new file mode 100644
index 000000000..0e3c14276
--- /dev/null
+++ b/docs/how-to/any_folder.rst
@@ -0,0 +1,29 @@
+Use Any Folder for Documentation
+================================
+
+Generally, your documentation must be ``docs/``,
+but the RST files for a module may live closer to the code they describe,
+for example in ``src/my_module/docs/``.
+You can symlink the folders by adding to your ``conf.py``:
+
+.. code-block:: python
+
+ score_any_folder_mapping = {
+ "../score/containers/docs": "component/containers",
+ }
+
+With this configuration, all files in ``score/containers/docs/`` become available at ``docs/component/containers/``.
+
+If you have ``docs/component/overview.rst``, for example,
+you can include the component documentation via ``toctree``:
+
+.. code-block:: rst
+
+ .. toctree::
+
+ containers/index
+
+Only relative links are allowed.
+
+The symlinks will show up in your sources.
+**Don't commit the symlinks to git!**
diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst
index 36888a92e..866910c3f 100644
--- a/docs/how-to/index.rst
+++ b/docs/how-to/index.rst
@@ -16,3 +16,4 @@ Here you find practical guides on how to use docs-as-code.
source_to_doc_links
test_to_doc_links
add_extensions
+ any_folder
diff --git a/src/BUILD b/src/BUILD
index 44bd84836..71e3a43f3 100644
--- a/src/BUILD
+++ b/src/BUILD
@@ -37,6 +37,7 @@ filegroup(
srcs = glob(
["*.py"],
) + [
+ "//src/extensions/score_any_folder:all_sources",
"//src/extensions/score_draw_uml_funcs:all_sources",
"//src/extensions/score_header_service:all_sources",
"//src/extensions/score_layout:all_sources",
diff --git a/src/extensions/docs/any_folder.rst b/src/extensions/docs/any_folder.rst
new file mode 100644
index 000000000..d69864ba6
--- /dev/null
+++ b/src/extensions/docs/any_folder.rst
@@ -0,0 +1,38 @@
+..
+ # *******************************************************************************
+ # 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
+ # *******************************************************************************
+
+Any Folder
+==========
+
+The extension ``score_any_folder`` allows documentation roots to stay in ``docs/``
+while pulling in source files from anywhere else in the repository.
+
+It does this by creating symlinks inside the Sphinx source directory (``confdir``) that point to the configured external directories.
+Sphinx then discovers and buildsthose files as if they were part of ``docs/`` from the start.
+
+The extension hooks into the ``builder-inited`` event,
+which fires before Sphinx reads any documents.
+
+Difference to Sphinx-Collections
+--------------------------------
+
+The extension `sphinx-collections `_
+is very similar to this extension.
+We use it for including external modules
+as it allows conditional inclusion
+and we need to switch between "normal" and "combo" builds.
+
+The relevant difference is that this extension allows to include folders to anywhere
+and is not restricted to a ``_collections/`` folder.
+We consider this additional control over folder placement necessary.
diff --git a/docs/internals/extensions/data_flow.png b/src/extensions/docs/data_flow.png
similarity index 100%
rename from docs/internals/extensions/data_flow.png
rename to src/extensions/docs/data_flow.png
diff --git a/docs/internals/extensions/extension_guide.md b/src/extensions/docs/extension_guide.md
similarity index 100%
rename from docs/internals/extensions/extension_guide.md
rename to src/extensions/docs/extension_guide.md
diff --git a/docs/internals/extensions/header_service.md b/src/extensions/docs/header_service.md
similarity index 100%
rename from docs/internals/extensions/header_service.md
rename to src/extensions/docs/header_service.md
diff --git a/docs/internals/extensions/index.rst b/src/extensions/docs/index.rst
similarity index 67%
rename from docs/internals/extensions/index.rst
rename to src/extensions/docs/index.rst
index 2ecec2d26..a9c48b306 100644
--- a/docs/internals/extensions/index.rst
+++ b/src/extensions/docs/index.rst
@@ -1,15 +1,16 @@
-.. # *******************************************************************************
- # Copyright (c) 2025 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
- # *******************************************************************************
+..
+ # *******************************************************************************
+ # Copyright (c) 2025 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
+ # *******************************************************************************
.. _extensions:
@@ -71,6 +72,16 @@ Hello there
`ubCode `__ VS Code extension.
Getting IDE support for Sphinx-Needs in a Bazel context made easy.
+ .. grid-item-card::
+
+ Any Folder
+ ^^^^^^^^^^
+
+ Learn about the :doc:`any_folder` extension that creates symlinks
+ from arbitrary repository locations into the docs folder,
+ allowing Sphinx to discover and build source files
+ that live outside the documentation root.
+
.. toctree::
@@ -83,3 +94,4 @@ Hello there
Source Code Linker
Extension Guide
Sync TOML
+ Any Folder
diff --git a/docs/internals/extensions/metamodel.md b/src/extensions/docs/metamodel.md
similarity index 100%
rename from docs/internals/extensions/metamodel.md
rename to src/extensions/docs/metamodel.md
diff --git a/docs/internals/extensions/rst_filebased_testing.md b/src/extensions/docs/rst_filebased_testing.md
similarity index 100%
rename from docs/internals/extensions/rst_filebased_testing.md
rename to src/extensions/docs/rst_filebased_testing.md
diff --git a/docs/internals/extensions/source_code_linker.md b/src/extensions/docs/source_code_linker.md
similarity index 100%
rename from docs/internals/extensions/source_code_linker.md
rename to src/extensions/docs/source_code_linker.md
diff --git a/docs/internals/extensions/sync_toml.rst b/src/extensions/docs/sync_toml.rst
similarity index 78%
rename from docs/internals/extensions/sync_toml.rst
rename to src/extensions/docs/sync_toml.rst
index 96a494d71..df2effb91 100644
--- a/docs/internals/extensions/sync_toml.rst
+++ b/src/extensions/docs/sync_toml.rst
@@ -1,3 +1,17 @@
+..
+ # *******************************************************************************
+ # 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
+ # *******************************************************************************
+
.. _`toml_sync`:
ubproject.toml sync
diff --git a/src/extensions/score_any_folder/BUILD b/src/extensions/score_any_folder/BUILD
new file mode 100644
index 000000000..0a5923278
--- /dev/null
+++ b/src/extensions/score_any_folder/BUILD
@@ -0,0 +1,50 @@
+# *******************************************************************************
+# 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("@aspect_rules_py//py:defs.bzl", "py_library")
+load("@docs_as_code_hub_env//:requirements.bzl", "requirement")
+load("@score_tooling//:defs.bzl", "score_py_pytest")
+
+filegroup(
+ name = "sources",
+ srcs = glob(["*.py"]),
+)
+
+filegroup(
+ name = "tests",
+ srcs = glob(["tests/*.py"]),
+)
+
+filegroup(
+ name = "all_sources",
+ srcs = [
+ ":sources",
+ ":tests",
+ ],
+ visibility = ["//visibility:public"],
+)
+
+py_library(
+ name = "score_any_folder",
+ srcs = [":sources"],
+ imports = ["."],
+ visibility = ["//visibility:public"],
+ deps = [requirement("sphinx")],
+)
+
+score_py_pytest(
+ name = "score_any_folder_tests",
+ size = "small",
+ srcs = glob(["tests/*.py"]),
+ deps = [":score_any_folder"],
+)
diff --git a/src/extensions/score_any_folder/__init__.py b/src/extensions/score_any_folder/__init__.py
new file mode 100644
index 000000000..13526b775
--- /dev/null
+++ b/src/extensions/score_any_folder/__init__.py
@@ -0,0 +1,118 @@
+# *******************************************************************************
+# 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
+# *******************************************************************************
+"""Sphinx extension that creates symlinks from arbitrary locations into the
+documentation source directory, allowing sphinx-build to include source
+files that live outside ``docs/``.
+
+Configuration in ``conf.py``::
+
+ score_any_folder_mapping = {
+ "../src/my_module/docs": "my_module",
+ }
+
+Each entry is a ``source: target`` pair where:
+
+* ``source`` – path to the directory to expose, relative to ``confdir``
+ (the directory containing ``conf.py``).
+* ``target`` – path of the symlink to create, relative to ``confdir``.
+
+The extension creates the symlinks on ``builder-inited``,
+before Sphinx starts reading any documents.
+Existing correct symlinks are left in place(idempotent);
+a symlink pointing to the wrong target is replaced.
+
+Symlinks created by this extension are removed again on ``build-finished``.
+Misconfigured pairs (absolute paths, non-symlink path at the target location)
+are logged as errors and skipped.
+"""
+
+from pathlib import Path
+
+from sphinx.application import Sphinx
+from sphinx.util.logging import getLogger
+
+logger = getLogger(__name__)
+
+_APP_ATTRIBUTE = "_score_any_folder_created_links"
+
+def setup(app: Sphinx) -> dict[str, str | bool]:
+ app.add_config_value("score_any_folder_mapping", default={}, rebuild="env")
+ app.connect("builder-inited", _create_symlinks)
+ app.connect("build-finished", _cleanup_symlinks)
+ return {
+ "version": "0.1",
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
+
+
+def _symlink_pairs(app: Sphinx) -> list[tuple[Path, Path]]:
+ """Return ``(resolved_source, link_path)`` pairs from the mapping."""
+ confdir = Path(app.confdir)
+ pairs = []
+ for source_rel, target_rel in app.config.score_any_folder_mapping.items():
+ if Path(source_rel).is_absolute():
+ logger.error(
+ "score_any_folder: source path must be relative, got: %r; skipping",
+ source_rel,
+ )
+ continue
+ if Path(target_rel).is_absolute():
+ logger.error(
+ "score_any_folder: target path must be relative, got: %r; skipping",
+ target_rel,
+ )
+ continue
+ source = (confdir / source_rel).resolve()
+ link = confdir / target_rel
+ pairs.append((source, link))
+ return pairs
+
+
+def _create_symlinks(app: Sphinx) -> None:
+ created_links: set[Path] = set()
+
+ for source, link in _symlink_pairs(app):
+ if link.is_symlink():
+ if link.resolve() == source:
+ logger.debug("score_any_folder: symlink already correct: %s", link)
+ continue
+ logger.info(
+ "score_any_folder: replacing stale symlink %s -> %s", link, source
+ )
+ link.unlink()
+ elif link.exists():
+ logger.error(
+ "score_any_folder: target path already exists and is not a symlink: "
+ "%s; skipping",
+ link,
+ )
+ continue
+
+ link.parent.mkdir(parents=True, exist_ok=True)
+ link.symlink_to(source)
+ created_links.add(link)
+ logger.debug("score_any_folder: created symlink %s -> %s", link, source)
+
+ setattr(app, _APP_ATTRIBUTE, created_links)
+
+
+def _cleanup_symlinks(app: Sphinx, exception: Exception | None) -> None:
+ del exception
+
+ created_links: set[Path] = getattr(app, _APP_ATTRIBUTE, set())
+ for link in created_links:
+ if not link.is_symlink():
+ continue
+ link.unlink()
+ logger.debug("score_any_folder: removed temporary symlink %s", link)
diff --git a/src/extensions/score_any_folder/tests/__init__.py b/src/extensions/score_any_folder/tests/__init__.py
new file mode 100644
index 000000000..ca5de742e
--- /dev/null
+++ b/src/extensions/score_any_folder/tests/__init__.py
@@ -0,0 +1,12 @@
+# *******************************************************************************
+# 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
+# *******************************************************************************
diff --git a/src/extensions/score_any_folder/tests/test_score_any_folder.py b/src/extensions/score_any_folder/tests/test_score_any_folder.py
new file mode 100644
index 000000000..0f04d6ce5
--- /dev/null
+++ b/src/extensions/score_any_folder/tests/test_score_any_folder.py
@@ -0,0 +1,177 @@
+# *******************************************************************************
+# 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
+# *******************************************************************************
+import os
+from collections.abc import Callable, Generator
+from contextlib import suppress
+from pathlib import Path
+
+import pytest
+from sphinx.testing.util import SphinxTestApp
+
+
+def _make_app(srcdir: Path, outdir: Path) -> SphinxTestApp:
+ original_cwd = None
+ with suppress(FileNotFoundError):
+ original_cwd = os.getcwd()
+ os.chdir(srcdir)
+ try:
+ return SphinxTestApp(
+ freshenv=True,
+ srcdir=srcdir,
+ confdir=srcdir,
+ outdir=outdir,
+ buildername="html",
+ )
+ finally:
+ if original_cwd is not None:
+ with suppress(FileNotFoundError, OSError):
+ os.chdir(original_cwd)
+
+
+@pytest.fixture
+def docs_dir(tmp_path: Path) -> Path:
+ d = tmp_path / "docs"
+ d.mkdir()
+ return d
+
+
+@pytest.fixture
+def make_sphinx_app(
+ docs_dir: Path, tmp_path: Path
+) -> Generator[Callable[[dict[str, str]], SphinxTestApp], None, None]:
+ """Factory: writes conf + index, returns a SphinxTestApp, cleans up on teardown."""
+ apps: list[SphinxTestApp] = []
+
+ def _factory(mapping: dict[str, str]) -> SphinxTestApp:
+ (docs_dir / "conf.py").write_text(
+ 'extensions = ["score_any_folder"]\n'
+ f"score_any_folder_mapping = {mapping!r}\n"
+ )
+ (docs_dir / "index.rst").write_text("Root\n====\n")
+ app = _make_app(docs_dir, tmp_path / "out")
+ apps.append(app)
+ return app
+
+ yield _factory
+
+ for app in apps:
+ app.cleanup()
+
+
+def test_symlink_exposes_files_at_target_path(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
+ docs_dir: Path,
+ tmp_path: Path,
+) -> None:
+ """Files in the source directory are readable via the symlinked target path."""
+ src_docs = tmp_path / "src" / "module_docs"
+ src_docs.mkdir(parents=True)
+ content = "Remote Page\n===========\n\nContent here.\n"
+ (src_docs / "page.rst").write_text(content)
+
+ make_sphinx_app({"../src/module_docs": "module"})
+
+ assert (docs_dir / "module" / "page.rst").read_text() == content
+
+
+def test_symlink_is_idempotent(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
+ docs_dir: Path,
+ tmp_path: Path,
+) -> None:
+ """Build cleanup removes temporary links and a second build still succeeds."""
+ src_docs = tmp_path / "external"
+ src_docs.mkdir()
+
+ make_sphinx_app({"../external": "notes"}).build()
+ link = docs_dir / "notes"
+ assert not link.exists()
+
+ make_sphinx_app({"../external": "notes"}).build()
+
+ assert not link.exists()
+
+
+def test_stale_symlink_is_replaced(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
+ docs_dir: Path,
+ tmp_path: Path,
+) -> None:
+ """A symlink pointing to a stale target is replaced with the correct one."""
+ correct_src = tmp_path / "correct"
+ correct_src.mkdir()
+ wrong_target = tmp_path / "wrong"
+ wrong_target.mkdir()
+ (docs_dir / "module").symlink_to(wrong_target)
+
+ make_sphinx_app({"../correct": "module"})
+
+ assert (docs_dir / "module").resolve() == correct_src.resolve()
+
+
+def test_existing_non_symlink_logs_error_and_skips(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
+ docs_dir: Path,
+ tmp_path: Path,
+) -> None:
+ """A real directory at the target path is left untouched and an error is logged."""
+ (tmp_path / "external").mkdir()
+ real_dir = docs_dir / "module"
+ real_dir.mkdir()
+
+ app: SphinxTestApp = make_sphinx_app({"../external": "module"})
+
+ assert real_dir.is_dir() and not real_dir.is_symlink()
+ assert "not a symlink" in app.warning.getvalue()
+
+
+def test_empty_mapping_is_a_no_op(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp], docs_dir: Path
+) -> None:
+ """An empty mapping produces no symlinks and no errors."""
+ make_sphinx_app({}).build()
+
+ assert [p for p in docs_dir.iterdir() if p.is_symlink()] == []
+
+
+def test_multiple_mappings(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
+ docs_dir: Path,
+ tmp_path: Path,
+) -> None:
+ """Multiple mapping entries each produce their own symlink."""
+ for name in ("alpha", "beta"):
+ (tmp_path / name).mkdir()
+
+ make_sphinx_app({"../alpha": "alpha", "../beta": "beta"})
+
+ for name in ("alpha", "beta"):
+ link = docs_dir / name
+ assert link.is_symlink(), f"symlink for {name!r} was not created"
+ assert link.resolve() == (tmp_path / name).resolve()
+
+
+def test_target_in_subfolder(
+ make_sphinx_app: Callable[[dict[str, str]], SphinxTestApp],
+ docs_dir: Path,
+ tmp_path: Path,
+) -> None:
+ """A target path with intermediate directories creates the parent dirs."""
+ src_docs = tmp_path / "external"
+ src_docs.mkdir()
+
+ make_sphinx_app({"../external": "foo/other"})
+
+ link = docs_dir / "foo" / "other"
+ assert link.is_symlink()
+ assert link.resolve() == src_docs.resolve()
diff --git a/src/extensions/score_source_code_linker/xml_parser.py b/src/extensions/score_source_code_linker/xml_parser.py
index d91358bdb..8432e1fc3 100644
--- a/src/extensions/score_source_code_linker/xml_parser.py
+++ b/src/extensions/score_source_code_linker/xml_parser.py
@@ -234,7 +234,7 @@ def build_test_needs_from_files(
tcns: list[DataOfTestCase] = []
for file in xml_paths:
# Last value can be ignored. The 'is_valid' function already prints infos
- test_cases, tests_missing_all_props,_ = read_test_xml_file(file)
+ test_cases, tests_missing_all_props, _ = read_test_xml_file(file)
non_prop_tests = ", ".join(n for n in tests_missing_all_props)
if non_prop_tests:
logger.info(f"Tests missing all properties: {non_prop_tests}")
diff --git a/src/extensions/score_sphinx_bundle/BUILD b/src/extensions/score_sphinx_bundle/BUILD
index c8d0a0a79..7fb69b719 100644
--- a/src/extensions/score_sphinx_bundle/BUILD
+++ b/src/extensions/score_sphinx_bundle/BUILD
@@ -25,6 +25,7 @@ py_library(
visibility = ["//visibility:public"],
deps = all_requirements + [
"@score_docs_as_code//src/extensions:score_plantuml",
+ "@score_docs_as_code//src/extensions/score_any_folder",
"@score_docs_as_code//src/extensions/score_draw_uml_funcs",
"@score_docs_as_code//src/extensions/score_header_service",
"@score_docs_as_code//src/extensions/score_layout",
diff --git a/src/extensions/score_sphinx_bundle/__init__.py b/src/extensions/score_sphinx_bundle/__init__.py
index 6ae04008b..152088365 100644
--- a/src/extensions/score_sphinx_bundle/__init__.py
+++ b/src/extensions/score_sphinx_bundle/__init__.py
@@ -31,6 +31,7 @@
"sphinxcontrib.mermaid",
"needs_config_writer",
"score_sync_toml",
+ "score_any_folder",
]
diff --git a/src/helper_lib/test_helper_lib.py b/src/helper_lib/test_helper_lib.py
index 0486821a5..5e335b2eb 100644
--- a/src/helper_lib/test_helper_lib.py
+++ b/src/helper_lib/test_helper_lib.py
@@ -29,7 +29,7 @@
class _FakeConfig:
"""Minimal stand-in for sphinx.config.Config."""
- def __init__(self, raw: dict):
+ def __init__(self, raw: dict[str, object]):
self._raw_config = raw