diff --git a/.gitignore b/.gitignore index 9aca50cb9..e5f5d2329 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,8 @@ user.bazelrc .ruff_cache # docs build artifacts -/_build* -docs/ubproject.toml +_build/ +ubproject.toml # Vale - editorial style guide .vale.ini diff --git a/docs.bzl b/docs.bzl index d89a1ac4c..a03d54e67 100644 --- a/docs.bzl +++ b/docs.bzl @@ -127,7 +127,7 @@ def _missing_requirements(deps): fail(msg) fail("This case should be unreachable?!") -def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = None, metamodel = None): +def docs(source_dir = "docs", data = [], deps = [], scan_code = [], test_sources = [], known_good = None, metamodel = None): """Creates all targets related to documentation. By using this function, you'll get any and all updates for documentation targets in one place. @@ -137,16 +137,13 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = data: Additional data files to include in the documentation build. deps: Additional dependencies for the documentation build. scan_code: List of code targets to scan for source code links. + test_sources: Optional list of repo-relative directory paths which will be used to filter testcases for documentation generation. + When empty (default), all testcases found in `bazel-testlogs` will be used. known_good: Optional label to a "known good" JSON file for source links. metamodel: Optional label to a metamodel.yaml file. When set, the extension loads this file instead of the default metamodel shipped with score_metamodel. """ - call_path = native.package_name() - - if call_path != "": - fail("docs() must be called from the root package. Current package: " + call_path) - metamodel_data = [] metamodel_env = {} metamodel_opts = [] @@ -207,12 +204,16 @@ def docs(source_dir = "docs", data = [], deps = [], scan_code = [], known_good = docs_env = { "SOURCE_DIRECTORY": source_dir, + "PACKAGE_DIR": native.package_name(), "DATA": str(data), + "TEST_SOURCES": str(test_sources), "SCORE_SOURCELINKS": "$(location :sourcelinks_json)", } | metamodel_env docs_sources_env = { "SOURCE_DIRECTORY": source_dir, + "PACKAGE_DIR": native.package_name(), "DATA": str(data_with_docs_sources), + "TEST_SOURCES": str(test_sources), "SCORE_SOURCELINKS": "$(location :merged_sourcelinks)", } | metamodel_env if known_good: @@ -342,8 +343,8 @@ def _sourcelinks_json(name, srcs): See https://eclipse-score.github.io/docs-as-code/main/how-to/source_to_doc_links.html Args: - name: Name of the target - srcs: Source files to scan for traceability tags + name: Name of the target. + srcs: Source files to scan for traceability tags. """ output_file = name + ".json" diff --git a/docs/how-to/faq.md b/docs/how-to/faq.md deleted file mode 100644 index dbfc5bdb0..000000000 --- a/docs/how-to/faq.md +++ /dev/null @@ -1,106 +0,0 @@ - - -# FAQ - -*docs-as-code is the S-CORE tool for building documentation, defining requirements and -verifying compliance.* - -In this document you will find answers to frequently asked questions regarding -docs-as-code and its usage. - - -## Why is docs-as-code so slow? - -The performance of docs-as-code has significantly improved in version 1.0.0 and later. - -## IDE support (auto completion, metamodel checks, preview, LSP capabilities) - -Since 1.0.0 IDE support works via esbonio. You'll need to install the esbonio extension -for your IDE (e.g., VSCode, PyCharm, etc.). Your repository must also be configured for -esbonio (settings.json). - -If this does not work, please use the live preview feature: `bazel run //:live_preview`. -This provides immediate metamodel feedback (although only on the console) and -IDE-agnostic preview capabilities. - -Another option is to use the [ubCode](https://ubcode.useblocks.com) extension for -VS Code. It provides lightning-fast linting, formatting, previewing, navigation and -analytical features for large Sphinx and Sphinx-Needs projects. -The `ubproject.toml` configuration file gets generated automatically when a Sphinx -build is running once. The file can safely be committed to the repository to enable -IDE support without requiring a Sphinx build first. - -### Esbonio - -Known issues: -* Python is required at startup, which is a problem for any Python-based LSP. We are - working to improve this by providing a devcontainer with Python preinstalled. - Additionally, we have submitted a feature request for Esbonio to handle Python - installation. - - For now please run `bazel run //:ide_support` and restart VS Code. - - -### ubc - -Currently, ubc is not aware of our metamodel. As a result, checks and auto-completion -features are not available. - -We plan to explore improvements in this area in the future together with useblocks. -These features are on the immediate roadmap. - - -## Do we need to write custom Python code for every Metamodel check? -With our current approach, allowed attributes and links for Needs are defined within the -`metamodel.yml` file. If the check can be fully described there (e.g., process -requirements are only allowed to link to stakeholder requirements), no custom code is -needed. It is also not necessary to write individual tests for every single check -performed by the metamodel. - -Only a few very specific checks require custom Python code beyond the generic metamodel -capabilities. These are cases that cannot be addressed by generic metamodel approaches -in any tool. For example: "the middle part of certain IDs must match the directory name -of the file." - - -## How can I be sure that the Metamodel does what I want it to do? -We use *examples* written in reStructuredText (rst) to verify that the metamodel has -been configured as intended. - -Metamodel checks are verified through standard testing practices, like any other code. -The examples mentioned above are helpful, but they are only examples. They are not -mandatory for verification of the metamodel checks. - - - -## Sphinx and safety -It is important to distinguish between metamodel checks and HTML rendering. - -Metamodel checks can be verified / qualified without Sphinx. - -If the renderer is safety-relevant, then qualification of Sphinx (and Sphinx Needs) is -required. This is currently under evaluation by the process team (@aschemmel-tech). - - -## What about versioning of requirements? -We are currently discussing possible implementations to enable linking to specific -versions of requirements (e.g. `implements: req-5@v3.0.0`). - - -## Sphinx traceability -It is possible to link requirements from other requirements, from source code, or from -tests (tests within the next days). - -### What about bazel targets? -Bazel targets are not involved in traceability (currently not required by process). diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 7cae52036..cb2d38a29 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -25,7 +25,6 @@ Here you find practical guides on how to use docs-as-code. get_started setup write_docs - faq other_modules dashboards_and_quality_gates source_to_doc_links diff --git a/src/extensions/score_metamodel/external_needs.py b/src/extensions/score_metamodel/external_needs.py index a39736661..980dfe1c2 100644 --- a/src/extensions/score_metamodel/external_needs.py +++ b/src/extensions/score_metamodel/external_needs.py @@ -31,10 +31,16 @@ class ExternalNeedsSource: bazel_module: str path_to_target: str target: str + # True for a same-repo mount (`//pkg:needs_json`), whose runfiles live under + # `_main/…`. False for a cross-module mount (`@repo//…:needs_json`), whose + # runfiles live under `{bazel_module}+/…`. + is_local: bool = False def _parse_bazel_external_need(s: str) -> ExternalNeedsSource | None: - if not s.startswith("@"): + is_cross_module = s.startswith("@") + is_local = s.startswith("//") + if not is_cross_module and not is_local: # Local need, not external needs return None @@ -46,16 +52,28 @@ def _parse_bazel_external_need(s: str) -> ExternalNeedsSource | None: ":", 1 ) # @score_process//:needs_json => [@score_process//, needs_json] repo, path_to_target = repo_and_path.split("//", 1) - repo = repo.lstrip("@") + repo = repo.lstrip("@") # empty for same-repo `//pkg:needs_json` - if path_to_target == "" and target in ("needs_json", "docs_sources"): + if target in ("needs_json", "docs_sources"): return ExternalNeedsSource( - bazel_module=repo, path_to_target=path_to_target, target=target + bazel_module=repo, + path_to_target=path_to_target, + target=target, + is_local=is_local, ) # Unknown data target. Probably not a needs.json file. return None +def _runfiles_module_dir(e: ExternalNeedsSource) -> str: + """Runfiles top-level directory holding this source's package tree. + + Same-repo mounts are staged under `_main/…`; cross-module mounts under the + module's bzlmod canonical name `{bazel_module}+/…`. + """ + return "_main" if e.is_local else f"{e.bazel_module}+" + + def parse_external_needs_sources_from_DATA(v: str) -> list[ExternalNeedsSource]: if v in ["[]", ""]: return [] @@ -150,7 +168,13 @@ def get_external_needs_source(external_needs_source: str) -> list[ExternalNeedsS def add_external_needs_json(e: ExternalNeedsSource, config: Config): - json_file_raw = f"{e.bazel_module}+/{e.target}/_build/needs/needs.json" + json_file_raw = ( + Path(_runfiles_module_dir(e)) + / e.path_to_target + / e.target + / "_build/needs/needs.json" + ) + r = get_runfiles_dir() json_file = r / json_file_raw logger.debug(f"External needs.json: {json_file}") @@ -176,22 +200,28 @@ def add_external_needs_json(e: ExternalNeedsSource, config: Config): def add_external_docs_sources(e: ExternalNeedsSource, config: Config): # Note that bazel does NOT write the files under e.target! - # {e.bazel_module}+ matches the original git layout! + # The runfiles layout mirrors the original git layout: same-repo mounts live + # under `_main/…`, cross-module mounts under `{e.bazel_module}+/…` + # (see _runfiles_module_dir). r = get_runfiles_dir() if "ide_support.runfiles" in str(r): logger.error("Combo builds are currently only supported with Bazel.") return - docs_source_path = Path(r) / f"{e.bazel_module}+" + docs_source_path = Path(r) / _runfiles_module_dir(e) / e.path_to_target + + # A cross-module root mount keeps its module name as the collection key + # (unchanged). Sub-package / same-repo mounts disambiguate via the path. + key = "/".join(c for c in (e.bazel_module, e.path_to_target) if c) or "_main" if "collections" not in config: config.collections = {} - config.collections[e.bazel_module] = { + config.collections[key] = { "driver": "symlink", "source": str(docs_source_path), - "target": e.bazel_module, + "target": key, } - logger.info(f"Added external docs source: {docs_source_path} -> {e.bazel_module}") + logger.info(f"Added external docs source: {docs_source_path} -> {key}") def connect_external_needs(app: Sphinx, config: Config): @@ -204,8 +234,6 @@ def connect_external_needs(app: Sphinx, config: Config): config.needs_external_needs = [] for e in external_needs: - assert not e.path_to_target # path_to_target is always empty - if e.target == "needs_json": add_external_needs_json(e, app.config) elif e.target == "docs_sources": diff --git a/src/extensions/score_metamodel/tests/rst/options/test_options_failure_effect.rst b/src/extensions/score_metamodel/tests/rst/options/test_options_failure_effect.rst index 9d0b9a1fc..6ef600c20 100644 --- a/src/extensions/score_metamodel/tests/rst/options/test_options_failure_effect.rst +++ b/src/extensions/score_metamodel/tests/rst/options/test_options_failure_effect.rst @@ -158,7 +158,7 @@ .. feat_saf_fmea:: Empty failure_effect :id: feat_saf_fmea__test__empty_failure_effect :fault_id: FMEA_05 - :failure_effect: + :failure_effect: :sufficient: yes :status: valid :expect: feat_saf_fmea__test__empty_failure_effect: is missing required attribute: `failure_effect`. diff --git a/src/extensions/score_metamodel/tests/test_external_needs.py b/src/extensions/score_metamodel/tests/test_external_needs.py index a9db5052e..a73ff601f 100644 --- a/src/extensions/score_metamodel/tests/test_external_needs.py +++ b/src/extensions/score_metamodel/tests/test_external_needs.py @@ -36,13 +36,54 @@ def test_empty_list(): assert parse_external_needs_sources_from_DATA("[]") == [] -def test_external_str_does_not_start_with_at(): - assert get_external_needs_source('["noatrepo//foo/bar:baz"]') == [] +def test_external_str_is_neither_at_nor_slash(): + # Labels that start with neither "@" nor "//" are not bazel needs sources. + assert get_external_needs_source('["noatrepo/foo/bar:baz"]') == [] -def test_single_entry_with_path(): +def test_same_repo_entry_with_path(): + # A same-repo `//pkg:needs_json` mount now parses as a local source that + # carries its sub-package path. + result = parse_external_needs_sources_from_DATA('["//foo/bar:needs_json"]') + assert result == [ + ExternalNeedsSource( + bazel_module="", + path_to_target="foo/bar", + target="needs_json", + is_local=True, + ) + ] + + +def test_same_repo_root_entry(): + result = parse_external_needs_sources_from_DATA('["//:needs_json"]') + assert result == [ + ExternalNeedsSource( + bazel_module="", + path_to_target="", + target="needs_json", + is_local=True, + ) + ] + + +def test_cross_module_sub_package_entry(): + # A cross-module sub-package target now parses (previously rejected) and + # keeps its path so the runfiles path can be built correctly. + result = parse_external_needs_sources_from_DATA('["@repo//foo/bar:needs_json"]') + assert result == [ + ExternalNeedsSource( + bazel_module="repo", + path_to_target="foo/bar", + target="needs_json", + is_local=False, + ) + ] + + +def test_single_entry_with_path_non_target(): + # A target that is not needs_json / docs_sources is not reported as an external needs source. result = parse_external_needs_sources_from_DATA('["@repo//foo/bar:baz"]') - # IF a target has a path, it will not be reported as external needs assert result == [] @@ -74,6 +115,7 @@ def test_multiple_entries(): def test_multiple_entries_2(): + # Both targets are named "needs_json" but one is a sub-package target, so the path is preserved. result = parse_external_needs_sources_from_DATA( '["@repo1//:needs_json", "@repo2//path:needs_json"]' ) @@ -81,7 +123,13 @@ def test_multiple_entries_2(): assert result == [ ExternalNeedsSource( bazel_module="repo1", path_to_target="", target="needs_json" - ) + ), + ExternalNeedsSource( + bazel_module="repo2", + path_to_target="path", + target="needs_json", + is_local=False, + ), ] @@ -120,6 +168,40 @@ def test_add_external_needs_json_appends_entry( assert Path(entry["json_path"]) == json_path +def test_add_external_needs_json_appends_entry_local( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A same-repo mount resolves under `_main///…`.""" + e = ExternalNeedsSource( + bazel_module="", + target="needs_json", + path_to_target="src/tests/e2e/external_needs/producer", + is_local=True, + ) + config = Config() + config.needs_external_needs = [] + + runfiles_dir = tmp_path + rel_json = Path( + "_main/src/tests/e2e/external_needs/producer/needs_json/_build/needs/needs.json" + ) + json_path = runfiles_dir / rel_json + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text( + json.dumps({"project_url": "https://example.test/local"}), encoding="utf-8" + ) + + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: runfiles_dir) + + add_external_needs_json(e, config) + + assert config.needs_external_needs is not None + assert len(config.needs_external_needs) == 1 + entry = config.needs_external_needs[0] + assert entry["base_url"] == "https://example.test/local/main" + assert Path(entry["json_path"]) == json_path + + def test_add_external_needs_json_missing_file_keeps_list_empty( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -161,6 +243,70 @@ def test_add_external_docs_sources_adds_collection( assert entry["target"] == "third_party_docs" +def test_add_external_docs_sources_local_sub_package( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A same-repo sub-package `docs_sources` mount resolves under `_main/`. + + Mirrors test_add_external_needs_json_appends_entry_local but for the + `docs_sources` path: the local branch stages under `_main/…`, appends + `path_to_target`, and the collection key falls through the + `bazel_module + path_to_target` join (bazel_module empty for a local mount). + """ + e = ExternalNeedsSource( + bazel_module="", + target="docs_sources", + path_to_target="src/tests/e2e/external_needs/producer", + is_local=True, + ) + config = Config() + config.collections = {} + + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: tmp_path) + + add_external_docs_sources(e, config) + + assert config.collections is not None + key = "src/tests/e2e/external_needs/producer" + assert key in config.collections + entry = config.collections[key] + assert entry["driver"] == "symlink" + assert entry["source"] == str( + tmp_path / "_main" / "src/tests/e2e/external_needs/producer" + ) + assert entry["target"] == key + + +def test_add_external_docs_sources_local_root_key_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A same-repo root `docs_sources` mount falls back to the `_main` key. + + With both bazel_module and path_to_target empty, the key join yields "" and + the `or "_main"` fallback names the collection, while the source stays at the + `_main` runfiles root. + """ + e = ExternalNeedsSource( + bazel_module="", + target="docs_sources", + path_to_target="", + is_local=True, + ) + config = Config() + config.collections = {} + + monkeypatch.setattr(ext_needs, "get_runfiles_dir", lambda: tmp_path) + + add_external_docs_sources(e, config) + + assert config.collections is not None + assert "_main" in config.collections + entry = config.collections["_main"] + assert entry["driver"] == "symlink" + assert entry["source"] == str(tmp_path / "_main") + assert entry["target"] == "_main" + + def test_add_external_docs_sources_ide_support_returns_without_changes( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/src/extensions/score_source_code_linker/__init__.py b/src/extensions/score_source_code_linker/__init__.py index 644a41324..e67fc4544 100644 --- a/src/extensions/score_source_code_linker/__init__.py +++ b/src/extensions/score_source_code_linker/__init__.py @@ -350,6 +350,17 @@ def setup(app: Sphinx) -> dict[str, str | bool]: types=bool, description="If True, render links as plain text without GitHub URLs (useful for Bazel sandbox builds)", ) + app.add_config_value( + "testcase_source_dirs", + default="", + rebuild="env", + types=str, + description=( + "str(list) of repo-relative directory paths. When set, the test-code-linker " + "only builds testcase needs for testcases whose source file lives under one of " + "these directories. Empty means no filtering (scan the whole workspace)." + ), + ) setup_once(app) return { diff --git a/src/extensions/score_source_code_linker/tests/test_xml_parser.py b/src/extensions/score_source_code_linker/tests/test_xml_parser.py index 83163a557..00c802e50 100644 --- a/src/extensions/score_source_code_linker/tests/test_xml_parser.py +++ b/src/extensions/score_source_code_linker/tests/test_xml_parser.py @@ -377,6 +377,96 @@ def _fake_add_external_need(**kwargs: object) -> object: ) +# ╭───────────────────────────────────────────────────────────────╮ +# │ Tests for testcase_source_dirs scoping (docs(test_sources=)) │ +# ╰───────────────────────────────────────────────────────────────╯ + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_is_testcase_in_scope_empty_allow_list_is_unfiltered(): + """An empty allow-list disables filtering: everything is in scope.""" + assert xml_parser.is_testcase_in_scope("src/tests/e2e/basic/x.py", []) is True + # Even a testcase without a `file` attribute is in scope when unfiltered. + assert xml_parser.is_testcase_in_scope(None, []) is True + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_is_testcase_in_scope_positive_match(): + """A file located under an allowed dir is in scope (positive match).""" + assert ( + xml_parser.is_testcase_in_scope("src/tests/e2e/basic/x.py", ["src/tests/e2e"]) + is True + ) + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_is_testcase_in_scope_not_under_any_allowed_dir(): + """A file outside every allowed dir is out of scope.""" + assert xml_parser.is_testcase_in_scope("src/other/x.py", ["src/tests/e2e"]) is False + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_is_testcase_in_scope_none_file_with_allow_list(): + """With a non-empty allow-list a testcase without a `file` is out of scope.""" + assert xml_parser.is_testcase_in_scope(None, ["src/tests/e2e"]) is False + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_is_testcase_in_scope_no_string_prefix_match(): + """Matching is component-wise, not a raw string-prefix match.""" + # "basic" starts with the allowed "ba" as a string, but not as a path + # component, so this must NOT be considered in scope. + assert ( + xml_parser.is_testcase_in_scope( + "src/tests/e2e/basic/x.py", ["src/tests/e2e/ba"] + ) + is False + ) + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_parse_testcase_source_dirs(): + """`str(list)` config values are parsed into a list of paths.""" + assert xml_parser.parse_testcase_source_dirs("[]") == [] + assert xml_parser.parse_testcase_source_dirs("") == [] + assert xml_parser.parse_testcase_source_dirs('["a", "b/c"]') == ["a", "b/c"] + + +@add_test_properties( + partially_verifies=["tool_req__docs_test_link_testcase"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_parse_testcase_source_dirs_non_list_raises(): + """A malformed (non-list) config value fails loudly instead of downstream.""" + with pytest.raises(ValueError, match="must be a list"): + xml_parser.parse_testcase_source_dirs('"not a list"') + + # ─────────────[ Boilerplate generated by CoPilot ]───────────── diff --git a/src/extensions/score_source_code_linker/xml_parser.py b/src/extensions/score_source_code_linker/xml_parser.py index 9483b6474..c40443ce6 100644 --- a/src/extensions/score_source_code_linker/xml_parser.py +++ b/src/extensions/score_source_code_linker/xml_parser.py @@ -22,6 +22,7 @@ import contextlib import hashlib import itertools +import json import os import xml.etree.ElementTree as ET from pathlib import Path @@ -54,6 +55,38 @@ logger.setLevel("DEBUG") +def parse_testcase_source_dirs(v: str) -> list[str]: + """Parse the `testcase_source_dirs` config value into a list of paths. + + The value arrives as a `str(list)` produced by Starlark (double-quoted, i.e. + valid JSON), mirroring how `external_needs_source` is parsed in + `score_metamodel.external_needs.parse_external_needs_sources_from_DATA`. + """ + if v in ("[]", ""): + return [] + parsed = json.loads(v) + if not isinstance(parsed, list): + raise ValueError( + f"testcase_source_dirs must be a list, got {type(parsed).__name__}: {v!r}" + ) + return parsed + + +def is_testcase_in_scope(test_file: str | None, allowed_dirs: list[str]) -> bool: + """Return True if the testcase should be turned into a need. + + An empty `allowed_dirs` disables filtering (everything is in scope). Otherwise + a testcase is in scope only if it has a `file` attribute that is located under + one of the allowed (repo-relative) directories. + """ + if not allowed_dirs: + return True + if not test_file: + return False + file_path = Path(test_file) + return any(file_path.is_relative_to(allowed) for allowed in allowed_dirs) + + def clean_test_file_name(raw_filepath: Path) -> Path: """ incoming path: @@ -163,7 +196,9 @@ def parse_properties(case_properties: dict[str, Any], properties: Element): return case_properties -def read_test_xml_file(file: Path) -> tuple[list[DataOfTestCase], list[str], list[str]]: +def read_test_xml_file( + file: Path, allowed_dirs: list[str] | None = None +) -> tuple[list[DataOfTestCase], list[str], list[str]]: """ Reading & parsing the test.xml files into TestCaseNeeds @@ -172,6 +207,7 @@ def read_test_xml_file(file: Path) -> tuple[list[DataOfTestCase], list[str], lis - list[TestCaseNeed] - list[str] => Testcase Names that did not have the required properties. """ + allowed_dirs = allowed_dirs or [] test_case_needs: list[DataOfTestCase] = [] non_prop_tests: list[str] = [] missing_prop_tests: list[str] = [] @@ -180,6 +216,15 @@ def read_test_xml_file(file: Path) -> tuple[list[DataOfTestCase], list[str], lis md = get_metadata_from_test_path(file) for testsuite in root.findall("testsuite"): for testcase in testsuite.findall("testcase"): + test_file = testcase.get("file") + # When testcase_source_dirs is configured, only testcases whose source + # file lives under one of the allowed directories are turned into needs. + # Out-of-scope testcases are skipped here (before the mandatory + # name/classname assertion below) so they are neither added as needs nor + # cached. Skipping early also keeps a scoped build robust against + # malformed test.xml files emitted by unrelated, out-of-scope tests. + if not is_testcase_in_scope(test_file, allowed_dirs): + continue case_properties = {} testcasename = testcase.get("name", "") testclassname = testcase.get("classname", "") @@ -192,7 +237,6 @@ def read_test_xml_file(file: Path) -> tuple[list[DataOfTestCase], list[str], lis testname = "__".join([testcn, testcasename]) else: testname = testcasename - test_file = testcase.get("file") line = testcase.get("line") # ╭──────────────────────────────────────╮ @@ -303,8 +347,18 @@ def run_xml_parser(app: Sphinx, env: BuildEnvironment): testlogs_dir = find_test_folder() if testlogs_dir is None: return + allowed_dirs = parse_testcase_source_dirs( + getattr(app.config, "testcase_source_dirs", "") + ) + if allowed_dirs: + logger.info( + f"Scoping testcase needs to source dirs: {allowed_dirs}", + type="score_source_code_linker", + ) xml_file_paths = find_xml_files(testlogs_dir) - test_case_needs = build_test_needs_from_files(app, env, xml_file_paths) + test_case_needs = build_test_needs_from_files( + app, env, xml_file_paths, allowed_dirs + ) # Saving the test case needs for cache logger.info( f"Saving {len(test_case_needs)} test case needs to the cache `score_testcaseneeds_cache.json` in _build/." @@ -324,12 +378,18 @@ def run_xml_parser(app: Sphinx, env: BuildEnvironment): def build_test_needs_from_files( - app: Sphinx, enw_: BuildEnvironment, xml_paths: list[Path] + app: Sphinx, + enw_: BuildEnvironment, + xml_paths: list[Path], + allowed_dirs: list[str] | None = None, ) -> list[DataOfTestCase]: """ Reading in all test.xml files, and building 'testcase' external need objects out of them. + When `allowed_dirs` is non-empty, only testcases whose source file lives under one + of those repo-relative directories are turned into needs (see read_test_xml_file). + Returns: - list[TestCaseNeed] """ @@ -337,7 +397,7 @@ def build_test_needs_from_files( for file in xml_paths: # Last value can be ignored. The 'is_valid' function already prints infos test_cases, tests_missing_all_props, tests_missing_some_props = ( - read_test_xml_file(file) + read_test_xml_file(file, allowed_dirs) ) non_prop_tests = ", ".join(n for n in tests_missing_all_props) if non_prop_tests: diff --git a/src/incremental.py b/src/incremental.py index c6f4cc8e2..0519dff63 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -96,25 +96,25 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: logger.info("Waiting for client to connect on port: " + str(args.debug_port)) debugpy.wait_for_client() - workspace = os.getenv("BUILD_WORKSPACE_DIRECTORY") - if workspace: - workspace += "/" - else: - workspace = "" + ws_root = Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) + # Docs source and output are resolved relative to the package where docs() + # was called. For the root BUILD, PACKAGE_DIR == "" so this is unchanged. + package_dir = ws_root / os.environ.get("PACKAGE_DIR", "") - build_dir = Path(workspace + "_build") + build_dir = package_dir / "_build" sentinel_files = [ - Path(workspace + "MODULE.bazel"), - Path(workspace + "MODULE.bazel.lock"), - Path(workspace + "BUILD"), + ws_root / "MODULE.bazel", + ws_root / "MODULE.bazel.lock", + package_dir / "BUILD", ] clean_builddir_if_stale(build_dir, sentinel_files) - warning_file = Path(workspace + "_build/warnings.txt") + warning_file = build_dir / "warnings.txt" + source_directory = get_env("SOURCE_DIRECTORY") base_arguments = [ - workspace + get_env("SOURCE_DIRECTORY"), - workspace + "_build", + str(package_dir / source_directory), + str(build_dir), "--warning-file", str(warning_file), "-W", # treat warning as errors @@ -123,13 +123,14 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: "--jobs", "auto", f"--define=external_needs_source={get_env('DATA')}", + f"--define=testcase_source_dirs={os.environ.get('TEST_SOURCES', '[]')}", ] metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "") if metamodel_yaml: # Normalize to absolute path so it resolves correctly after Sphinx changes cwd if not os.path.isabs(metamodel_yaml): - metamodel_yaml = workspace + metamodel_yaml + metamodel_yaml = str(ws_root / metamodel_yaml) metamodel_yaml = os.path.abspath(metamodel_yaml) base_arguments.append(f"--define=score_metamodel_yaml={metamodel_yaml}") @@ -141,16 +142,14 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: base_arguments.append(f"-A=github_user={github_user}") base_arguments.append(f"-A=github_repo={github_repo}") base_arguments.append("-A=github_version=main") - base_arguments.append(f"-A=doc_path={get_env('SOURCE_DIRECTORY')}") + base_arguments.append(f"-A=doc_path={package_dir / source_directory}") if os.getenv("KNOWN_GOOD_JSON"): base_arguments.append(f"--define=KNOWN_GOOD_JSON={get_env('KNOWN_GOOD_JSON')}") action = get_env("ACTION") if action == "live_preview": - Path(workspace + "/_build/score_source_code_linker_cache.json").unlink( - missing_ok=True - ) + (build_dir / "score_source_code_linker_cache.json").unlink(missing_ok=True) sphinx_autobuild_main( base_arguments + [