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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/test_and_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ jobs:
merge-multiple: true
- name: Write unit test and coverage summaries
# Module unit tests and their coverage come only from module_tests.yml, which runs each
# module's own tests against ref_int's resolved dependency versions.
# module's own tests against ref_int's resolved dependency versions. The report command
# also combines their rebased LCOV into coverage-report/coverage_report.dat, the input
# read by docs-as-code when //:docs_shim runs in this workspace below.
run: python ./scripts/module_tests.py report _module_reports
- name: Collect test reports for the documentation
# The source code linker reads tests-report/ instead of bazel-testlogs/ when it exists, so
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ _build
ubproject.toml
/docs/_collections
/tests-report
/coverage-report

# Workspace files
/score_*/
Expand Down
2 changes: 1 addition & 1 deletion bazel_common/score_modules_tooling.MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ git_override(
bazel_dep(name = "score_docs_as_code")
git_override(
module_name = "score_docs_as_code",
commit = "f1b30375e49646d95ca52565c41f5b926b0aa884",
commit = "946fd135045841db63e18ffd532374963e84a220",
remote = "https://github.com/eclipse-score/docs-as-code.git",
)

Expand Down
31 changes: 31 additions & 0 deletions docs/integration_process/step_7_reporting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,37 @@ measured. Build a single module's report with:

bazel run //rust_coverage:rust_coverage_score_my_module

Component coverage in the documentation
---------------------------------------

``scripts/module_tests.py`` uses ``--config=llvm_cov`` and exports reports through
``@score_coverage//:generate_coverage_html`` for the integrated Baselibs,
Persistency and Logging releases. Other modules run their unit tests
without coverage until a release using ``coverage_tool`` is integrated; the
activation points are commented in ``COVERAGE_MODULES`` in the script.

The export uses ``COVERAGE_THRESHOLD=0`` to collect evidence without adding a
percentage gate. It contains plain LCOV at ``coverage/coverage_report.dat`` and
the detailed HTML report. Source paths are translated to the reference
integration's repository identities before upload; for example,
``score/foo.cpp`` in Baselibs becomes
``external/score_baselibs+/score/foo.cpp``.

The ``report`` command combines these files into
``coverage-report/coverage_report.dat``, which docs-as-code reads when the
published documentation is built. Component coverage uses only the
``primary_need_id`` and ``code_targets`` already exported by the pinned modules.
Missing ownership or measurements remain unavailable; the integration does not
add component metadata through patches.

To include coverage in a local documentation build:

.. code-block:: bash

python3 scripts/module_tests.py run score_persistency
python3 scripts/module_tests.py report _module_tests/reports
bazel run //:docs_shim

Verification & status reports
-----------------------------

Expand Down
2 changes: 1 addition & 1 deletion known_good.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@
},
"score_docs_as_code": {
"repo": "https://github.com/eclipse-score/docs-as-code.git",
"hash": "f1b30375e49646d95ca52565c41f5b926b0aa884"
"hash": "946fd135045841db63e18ffd532374963e84a220"
Comment thread
AlexanderLanin marked this conversation as resolved.
Resolved
},
"score_process_description": {
"repo": "https://github.com/eclipse-score/process_description.git",
Expand Down
134 changes: 120 additions & 14 deletions scripts/module_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@
REF_INT = Path(__file__).resolve().parents[1]
UNIFIED_RC = REF_INT / "bazel_common" / "unified.bazelrc"
UNIFIED_TOOLCHAINS = REF_INT / "bazel_common" / "unified_toolchains.MODULE.bazel"
# Only enable the coverage_tool flow for releases already integrated in known_good.json.
COVERAGE_MODULES = {
"score_baselibs",
"score_persistency",
"score_logging",
# "score_kyron", # Enable once ref_int integrates a release using coverage_tool.
# "score_communication", # Enable once ref_int integrates a release using coverage_tool.
# "score_lifecycle", # Enable once ref_int integrates a release using coverage_tool.
# "score_time", # Enable once ref_int integrates a release using coverage_tool.
# "score_config_management", # Enable once ref_int integrates a release using coverage_tool.
}
# Bazel's per-target result lines, e.g. "//score/foo:bar_test FAILED in 2.1s".
STATUS_LINE = re.compile(r"^(//\S+|@\S+)\s+(FAILED TO BUILD|FAILED|TIMEOUT|NO STATUS|INCOMPLETE|FLAKY)\b", re.M)

Expand Down Expand Up @@ -108,18 +119,46 @@ def override_rc(paths: dict[str, Path], extra_test_config: list[str] = ()) -> st
return rc + "".join(f"build --{flag}\n" for flag in extra_test_config)


def coverage_args(test_args: list[str], langs: list[str], excludes: list[str] = ()) -> list[str]:
def coverage_args(
test_args: list[str], langs: list[str], excludes: list[str] = (), *, enabled: bool = True, module: str = ""
) -> list[str]:
"""The module's own test args with ref_int's unified config added before any ``--``.

Modules awaiting coverage_tool integration run their unit tests without coverage.
``excludes`` (the module's ``exclude_test_targets``) become negative patterns after ``--``.
"""
ours = ["--config=unified", "--combined_report=lcov"]
ours = ["--config=unified"]
if enabled:
ours.extend(["--build_tests_only", "--config=llvm_cov"])
if "rust" in langs:
ours.append("--config=unified-rust")
split = test_args.index("--") if "--" in test_args else len(test_args)
targets = [*test_args[split + 1 :], *(f"-{target}" for target in excludes)]
flags = test_args[:split]
targets = list(test_args[split + 1 :])
if enabled and module == "score_baselibs":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardcoded sounds not a good idea to me. Can we have a list of modules in config

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah true. I also have a full coverage config in coverage_tool tests. But currently no idea how to reuse it. As I dont want to encode it into the actual tool. Ideally we would not need it, but we are not there yet.

https://github.com/etas-contrib/score_coverage_tool/blob/1960b239037127549f2c15f10d49a50c1c258947/tests/compatibility/test_consumers.py#L30-L60

# Let llvm_cov select Clang without the module's GCC configuration.
flags = [arg for arg in flags if arg != "--config=score-linux-x86_64"]
targets.extend(
[
# This library interposes __cxa_allocate_exception; ld.lld rejects the
# duplicate definition from the statically linked C++ runtime.
"-//score/language/safecpp/aborts_upon_exception/...",
# These tests require GCC FP semantics or fail gMock's leak check
# with static linking. Keep the exclusions specific to coverage.
"-//score/language/safecpp/safe_math/details:floating_point_environment_test",
"-//score/os/linux/utils/test:network_interface_test",
]
)
targets.extend(f"-{target}" for target in excludes)
separator = ["--"] if targets else []
return ["coverage", "--lockfile_mode=update", *test_args[:split], *ours, *separator, *targets]
return [
"coverage" if enabled else "test",
"--lockfile_mode=update",
*flags,
*ours,
*separator,
*targets,
]


def _status(case: ET.Element) -> str:
Expand Down Expand Up @@ -173,11 +212,64 @@ def _append_unified_toolchain(workspace: Path) -> None:
f.write(f'\ninclude("//:{UNIFIED_TOOLCHAINS.name}")\n')


def _collect_coverage(workspace: Path, out: Path, env: dict) -> None:
(out / "coverage").mkdir()
lcov = Path(bazel(["info", "output_path"], workspace, env).stdout.strip()) / "_coverage" / "_coverage_report.dat"
if lcov.is_file():
shutil.copy(lcov, out / "coverage" / "lcov.dat")
def normalize_lcov(text: str, module: str) -> str:
"""Rebase coverage_tool's canonical paths into ref_int's external module namespace."""
lines = []
for line in text.splitlines():
if line.startswith("SF:"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks a bit fuzzy here. I would say lets use pythons path tooling and refactor

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately I dont have an idea how to improve that

path = line[3:]
if path.startswith("/"):
raise ValueError(f"{module}: coverage source must be repository-relative: {path}")
if path.startswith("../"):
path = "external/" + path[3:]
if not path.startswith("external/"):
path = f"external/{module}+/{path}"
parts = path.split("/")
if len(parts) < 3 or any(part in ("", ".", "..", "_virtual_includes") for part in parts):
raise ValueError(f"{module}: coverage source is not canonical: {path}")
if parts[1] == "_main":
parts[1] = f"{module}+"
line = "SF:" + "/".join(parts)
lines.append(line)
return "\n".join(lines) + "\n" if lines else ""


def combine_reports(reports: Path, modules: list[str], output: Path) -> None:
"""Combine available module reports, leaving absent measurements unavailable."""
chunks = []
for module in sorted(modules):
report = reports / module / "coverage" / "coverage_report.dat"
if report.is_file():
chunks.append(report.read_text(encoding="utf-8"))
else:
print(f"::warning::{module}: no documentation coverage report available")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text("".join(chunk.rstrip("\n") + "\n" for chunk in chunks), encoding="utf-8")


def _collect_coverage(workspace: Path, out: Path, env: dict) -> int:
# Export through coverage_tool's public CLI. It owns the backend's report packaging;
# ref_int only translates the exported source identities into its workspace context.
# Threshold zero collects evidence without adding a new percentage gate to module tests.
result = bazel(
[
"run",
"--lockfile_mode=update",
"@score_coverage//:generate_coverage_html",
"--",
"--testlogs-subdir=",
"--archive-dir",
str(out / "coverage"),
],
workspace,
{**env, "COVERAGE_THRESHOLD": "0"},
)
print(result.stdout)
if result.returncode:
return result.returncode
lcov = out / "coverage" / "coverage_report.dat"
lcov.write_text(normalize_lcov(lcov.read_text(encoding="utf-8"), out.name), encoding="utf-8")
return 0


def _write_summary(
Expand Down Expand Up @@ -241,7 +333,14 @@ def run(args: argparse.Namespace) -> int:
unpinned = sorted(shared - set(pinned))
shutil.copy(rc, out / "overrides.bazelrc")

command = coverage_args(module.metadata.test_args, module.metadata.langs, module.metadata.exclude_test_targets)
coverage_enabled = module.name in COVERAGE_MODULES
command = coverage_args(
module.metadata.test_args,
module.metadata.langs,
module.metadata.exclude_test_targets,
enabled=coverage_enabled,
module=module.name,
)
exit_code, log = streamed(["bazel", *command], workspace, env)

results = list((workspace / "bazel-testlogs").glob("**/test.xml"))
Expand All @@ -252,7 +351,9 @@ def run(args: argparse.Namespace) -> int:
# Bazel exits 0 when every test is skipped (e.g. all incompatible with the platform).
no_tests = not results
exit_code = exit_code or int(no_tests)
_collect_coverage(workspace, out, env)
if coverage_enabled:
coverage_exit = _collect_coverage(workspace, out, env)
exit_code = exit_code or coverage_exit
(out / "result.json").write_text(json.dumps({"exit_code": exit_code}) + "\n")
_write_summary(module.name, out, exit_code, log, no_tests=no_tests, pinned=pinned, unpinned=unpinned)
return exit_code
Expand All @@ -270,10 +371,10 @@ def report(args: argparse.Namespace) -> int:
result_file = reports / "result.json"
result = json.loads(result_file.read_text()) if result_file.is_file() else {"exit_code": 1}
unit_tests[module.name] = {**count_testcases(reports / "tests"), "exit_code": result["exit_code"]}
lcov = reports / "coverage" / "lcov.dat"
if "cpp" in module.metadata.langs:
lcov = reports / "coverage" / "coverage_report.dat"
if module.name in COVERAGE_MODULES:
numbers = lcov_percentages(lcov) if lcov.is_file() else {}
coverage[f"{module.name}_cpp"] = {**numbers, "exit_code": 0 if numbers.get("lines") else 1}
coverage[module.name] = {**numbers, "exit_code": 0 if numbers.get("lines") else 1}

args.out_dir.mkdir(parents=True, exist_ok=True)
unit_tests_md = generate_markdown_report(
Expand All @@ -289,6 +390,11 @@ def report(args: argparse.Namespace) -> int:
output_path=args.out_dir / "coverage_summary.md",
)
append_to_step_summary(unit_tests_md, coverage_md)
combine_reports(
args.reports_dir,
sorted(COVERAGE_MODULES & known.modules["target_sw"].keys()),
REF_INT / "coverage-report" / "coverage_report.dat",
)
return 0


Expand Down
Loading
Loading