From f7887e9aa212274775bd0a19543f94beb4c9e5a5 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 21 Aug 2026 14:27:04 +0000 Subject: [PATCH 1/2] feat(docs_and_test): add Bazel macro chaining tests/coverage with docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs_and_test macro generates two py_binary targets that run 'bazel test' (or 'bazel coverage --combined_report=lcov' when coverage=True) followed by 'bazel run //:docs' resp. '//:live_preview' in a single command: load('@score_docs_as_code//:bzl/docs_and_test.bzl', 'docs_and_test') docs_and_test( name = 'module_verification_report', test_targets = ['//score/...'], ) Consumers get //: and //:_preview without any local Python driver. Extra Bazel flags forward via '--test-flag=…'. Ctrl+C in the driver propagates cleanly as exit 130. Ships the driver via exports_files so external py_binary(srcs=…) can reference it. --- BUILD | 1 + bzl/docs_and_test.bzl | 124 +++++++++++++++++++++++++++++++++++++++ bzl/run_docs_and_test.py | 118 +++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 bzl/docs_and_test.bzl create mode 100644 bzl/run_docs_and_test.py diff --git a/BUILD b/BUILD index bb79832c2..843b7b814 100644 --- a/BUILD +++ b/BUILD @@ -17,6 +17,7 @@ package(default_visibility = ["//visibility:public"]) exports_files([ "default_conf.py.tpl", "pyproject.toml", + "bzl/run_docs_and_test.py", ]) docs( diff --git a/bzl/docs_and_test.bzl b/bzl/docs_and_test.bzl new file mode 100644 index 000000000..3a1cef3e9 --- /dev/null +++ b/bzl/docs_and_test.bzl @@ -0,0 +1,124 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Bazel macro that chains ``bazel test`` (or ``bazel coverage``) with a +docs target in one command. + +Usage in a consumer ``BUILD``:: + + load("@score_docs_as_code//:bzl/docs_and_test.bzl", "docs_and_test") + + docs_and_test( + name = "docs_full", + test_targets = ["//score/..."], + ) + +Generates two ``py_binary`` targets:: + + bazel run //:docs_full # tests/coverage, then //:docs + bazel run //:docs_full_preview # tests/coverage, then //:live_preview + +Bazel itself has no mechanism to make a build target depend on the +execution of a test, so the orchestration lives outside the dependency +graph in a small Python driver shipped with this module. +""" + +load("@rules_python//python:defs.bzl", "py_binary") + +_DEFAULT_DRIVER = Label("@score_docs_as_code//:bzl/run_docs_and_test.py") + +def _pipeline_binary(name, driver, test_targets, coverage, run_target, help_text): + py_binary( + name = name, + srcs = [driver], + main = driver, + args = [ + "--tests", + ",".join(test_targets), + "--coverage" if coverage else "--no-coverage", + "--docs", + run_target, + ], + tags = ["cli_help=%s:\nbazel run //:%s" % (help_text, name)], + ) + +def docs_and_test( + name, + test_targets, + coverage = True, + docs_target = "//:docs", + preview_target = "//:live_preview", + driver = None): + """Create ``py_binary`` targets that run tests, then a docs command. + + Two targets are generated: + + * ```` — runs tests, then ``docs_target``. + * ``_preview`` — runs tests, then ``preview_target`` + (typically ``//:live_preview``). + + Coverage is on by default. When ``coverage = True`` the pipeline uses + ``bazel coverage --combined_report=lcov`` on ``test_targets`` instead of + plain ``bazel test``; that runs the same tests with LLVM/GCC coverage + instrumentation and produces the aggregated LCOV at + ``bazel-out/_coverage/_coverage_report.dat`` in a single rebuild. + Targets without unit tests simply contribute no coverage data. Set + ``coverage = False`` to fall back to plain ``bazel test`` (faster on + first run, no LCOV). + + Extra Bazel CLI flags (e.g. ``--config=bl-x86_64-linux``) are not + hard-coded in the ``BUILD`` file. Pass them on the command line after + ``--``:: + + bazel run //:docs_full -- \\ + --test-flag=--config=bl-x86_64-linux + + ``--test-flag`` is repeatable and forwarded to whichever underlying + Bazel command runs (``test`` or ``coverage``). + + Args: + name: Base target name; invoke with ``bazel run //:`` or + ``bazel run //:_preview``. + test_targets: Bazel labels/patterns for the test/coverage step + (e.g. ``["//score/..."]``). Pass ``[]`` to skip. + coverage: If ``True`` (default), replace ``bazel test`` with + ``bazel coverage --combined_report=lcov`` so the docs build can + pick up per-source-file LCOV data. If ``False``, run plain + ``bazel test`` and produce no LCOV. + docs_target: Label of the docs binary to invoke via ``bazel run``. + Defaults to ``//:docs``. + preview_target: Label of the live-preview binary to invoke via + ``bazel run``. Defaults to ``//:live_preview``. Pass ``None`` to + skip generating the preview target. + driver: Label of the Python driver script. Defaults to the driver + shipped with ``score_docs_as_code``; only override when you want + to inject a custom driver. + """ + driver = driver or _DEFAULT_DRIVER + _pipeline_binary( + name = name, + driver = driver, + test_targets = test_targets, + coverage = coverage, + run_target = docs_target, + help_text = "Run tests, then build documentation", + ) + + if preview_target: + _pipeline_binary( + name = name + "_preview", + driver = driver, + test_targets = test_targets, + coverage = coverage, + run_target = preview_target, + help_text = "Run tests, then start the docs live preview", + ) diff --git a/bzl/run_docs_and_test.py b/bzl/run_docs_and_test.py new file mode 100644 index 000000000..4e543dd21 --- /dev/null +++ b/bzl/run_docs_and_test.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 +# ******************************************************************************* +"""Driver for the :bzl:`docs_and_test` macro. + +Runs either ``bazel test`` or ``bazel coverage`` on the configured targets, +then ``bazel run`` on the docs target. Aborts the pipeline on the first +non-zero exit code so a failing step does not silently ship stale docs. + +Extra Bazel CLI flags (typically ``--config=…``) are **not** baked into +the ``BUILD`` file; pass them at ``bazel run`` time after ``--``:: + + bazel run //:docs_full -- \\ + --test-flag=--config=bl-x86_64-linux + +``--test-flag`` is repeatable and forwarded to whichever underlying Bazel +command runs (``test`` or ``coverage``). + +The script must be invoked from the workspace root — ``bazel run`` sets +``BUILD_WORKSPACE_DIRECTORY`` accordingly, so we chdir there before +invoking any nested Bazel commands. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + + +def _split(csv: str) -> list[str]: + return [x for x in csv.split(",") if x] + + +def _run(cmd: list[str]) -> None: + print(f">>> {' '.join(cmd)}", flush=True) + try: + result = subprocess.run(cmd, check=False) + except KeyboardInterrupt: + # Ctrl+C hits both us and the child via the process group. The child + # already exited with 130; propagate the same status without dumping + # a Python traceback so `docs_full_preview` behaves like a bare + # `bazel run //:live_preview`. + sys.exit(130) + if result.returncode != 0: + print( + f"!!! step failed with exit code {result.returncode}: {' '.join(cmd)}", + file=sys.stderr, + ) + sys.exit(result.returncode) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--tests", + default="", + help="Comma-separated Bazel labels/patterns for the test step. " + "Empty string skips the step entirely.", + ) + parser.add_argument( + "--coverage", + dest="coverage", + action=argparse.BooleanOptionalAction, + default=True, + help="Run 'bazel coverage --combined_report=lcov' instead of " + "'bazel test' on --tests. Default: True.", + ) + parser.add_argument( + "--docs", + required=True, + help="Bazel label of the docs binary to invoke via 'bazel run'.", + ) + parser.add_argument( + "--test-flag", + action="append", + default=[], + help="Extra CLI flag forwarded to the test/coverage step " + "(repeatable). Typically '--config=…'.", + ) + args = parser.parse_args() + + # `bazel run` sets BUILD_WORKSPACE_DIRECTORY to the workspace root; nested + # bazel invocations must run from there so they see MODULE.bazel etc. + workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if workspace: + os.chdir(workspace) + + test_targets = _split(args.tests) + + if test_targets: + if args.coverage: + _run( + [ + "bazel", + "coverage", + "--combined_report=lcov", + *args.test_flag, + *test_targets, + ] + ) + else: + _run(["bazel", "test", *args.test_flag, *test_targets]) + _run(["bazel", "run", args.docs]) + + +if __name__ == "__main__": + main() From 52faf9c7a837e23f09b665582106559e6aaf0b21 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Wed, 26 Aug 2026 07:05:49 +0000 Subject: [PATCH 2/2] fix(docs_and_test): insert -- before test targets for negative bazel patterns Negative Bazel target patterns (e.g. '-//foo:bar') passed via test_targets were mistaken for CLI flags by 'bazel test'/'bazel coverage'. Insert '--' as a separator before the targets so they are always parsed as target patterns. (Split out of eclipse-score/docs-as-code#730, which also fixed an unrelated zero-coverage-row rendering bug in the score_module_verification_report extension.) --- bzl/run_docs_and_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bzl/run_docs_and_test.py b/bzl/run_docs_and_test.py index 4e543dd21..ad59380cc 100644 --- a/bzl/run_docs_and_test.py +++ b/bzl/run_docs_and_test.py @@ -106,11 +106,12 @@ def main() -> None: "coverage", "--combined_report=lcov", *args.test_flag, + "--", *test_targets, ] ) else: - _run(["bazel", "test", *args.test_flag, *test_targets]) + _run(["bazel", "test", *args.test_flag, "--", *test_targets]) _run(["bazel", "run", args.docs])