From c25359e1298440f0ac39af30dd3188ffb0a8bb96 Mon Sep 17 00:00:00 2001 From: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:54:17 +0200 Subject: [PATCH 1/4] coverage: add Ferrocene Rust coverage tool and docs - add ferrocene_report orchestration and repo-local wrapper rule - support line-coverage parsing and gating - default reports to bazel-bin/coverage and add overall summary - improve path remapping, crate resolutio and runfiles handling - update README/docs for developers and integrators Signed-off-by: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> --- README.md | 23 + coverage/BUILD | 26 + coverage/README.md | 163 ++++++ coverage/coverage.bzl | 90 +++ coverage/ferrocene_report.sh | 931 +++++++++++++++++++++++++++++++ coverage/llvm_profile_wrapper.sh | 12 + defs.bzl | 3 + tools/README.md | 9 +- 8 files changed, 1254 insertions(+), 3 deletions(-) create mode 100644 coverage/BUILD create mode 100644 coverage/README.md create mode 100644 coverage/coverage.bzl create mode 100755 coverage/ferrocene_report.sh create mode 100755 coverage/llvm_profile_wrapper.sh diff --git a/README.md b/README.md index a2d6fc0..bded050 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ See the individual README files for detailed usage instructions and configuratio | **python_basics** | Python development utilities and testing | [README](python_basics/README.md) | | **starpls** | Starlark language server support | [README](starpls/README.md) | | **tools** | Formatters & Linters | [README](tools/README.md) | +| **coverage** | Ferrocene Rust coverage workflow | [README](coverage/README.md) | ## Usage Examples @@ -32,6 +33,27 @@ Load tools in your `BUILD` files: ```starlark load("@score_tooling//:defs.bzl", "score_py_pytest") load("@score_tooling//:defs.bzl", "cli_tool") +load("@score_tooling//coverage:coverage.bzl", "rust_coverage_report") +``` + +Create a repo-local coverage target: + +```starlark +rust_coverage_report( + name = "rust_coverage", + bazel_configs = [ + "ferrocene-x86_64-linux", + "ferrocene-coverage", + ], + query = 'kind("rust_test", //...)', + min_line_coverage = "80", +) +``` + +Then run: + +```bash +bazel run //:rust_coverage -- --min-line-coverage 80 ``` ## Upgrading from separate MODULES @@ -53,6 +75,7 @@ The available import targets are: - cli_helper - use_format_targets - setup_starpls +- rust_coverage_report ## Format the tooling repository ```bash diff --git a/coverage/BUILD b/coverage/BUILD new file mode 100644 index 0000000..4f60fd1 --- /dev/null +++ b/coverage/BUILD @@ -0,0 +1,26 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +package(default_visibility = ["//visibility:public"]) + +sh_binary( + name = "ferrocene_report", + srcs = ["ferrocene_report.sh"], + visibility = ["//visibility:public"], +) + +sh_binary( + name = "llvm_profile_wrapper", + srcs = ["llvm_profile_wrapper.sh"], + visibility = ["//visibility:public"], +) diff --git a/coverage/README.md b/coverage/README.md new file mode 100644 index 0000000..dbc46f9 --- /dev/null +++ b/coverage/README.md @@ -0,0 +1,163 @@ +# Ferrocene Rust Coverage + +This directory provides the Ferrocene Rust coverage workflow for Bazel-based +projects. It uses Ferrocene's `symbol-report` and `blanket` tools to generate +HTML coverage reports from `.profraw` files produced by Rust tests. + +The workflow is intentionally split: +- Tests produce `.profraw` files (can run on host or target hardware). +- Reports are generated later on a host machine. + +This makes it easy to collect coverage from cross-compiled tests or from +hardware-in-the-loop runs. + +## Quick Start (Developers) + +1) Run tests with coverage enabled: + +```bash +bazel test --config=ferrocene-x86_64-linux --config=ferrocene-coverage \ + --nocache_test_results \ + //path/to:rust_tests +``` + +2) Generate coverage reports: + +```bash +bazel run //:rust_coverage -- --min-line-coverage 80 +``` + +The default report directory is: + +``` +$(bazel info bazel-bin)/coverage/rust-tests//blanket/index.html +``` + +The script prints per-target line coverage plus an overall summary line. + +## Integrator Setup + +### 1) MODULE.bazel + +Add `score_tooling` and `score_toolchains_rust` as dependencies: + +```starlark +bazel_dep(name = "score_tooling", version = "1.0.0") +bazel_dep(name = "score_toolchains_rust", version = "0.4.0") +``` + +### 2) .bazelrc + +Add a Ferrocene coverage config. Names are examples; choose names that fit +your repo: + +``` +# Ferrocene toolchain for host execution +build:ferrocene-x86_64-linux --host_platform=@score_bazel_platforms//:x86_64-linux +build:ferrocene-x86_64-linux --platforms=@score_bazel_platforms//:x86_64-linux +build:ferrocene-x86_64-linux --extra_toolchains=@score_toolchains_rust//toolchains/ferrocene:ferrocene_x86_64_unknown_linux_gnu + +# Coverage flags for rustc +build:ferrocene-coverage --@rules_rust//rust/settings:extra_rustc_flag=-Cinstrument-coverage +build:ferrocene-coverage --@rules_rust//rust/settings:extra_rustc_flag=-Clink-dead-code +build:ferrocene-coverage --@rules_rust//rust/settings:extra_rustc_flag=-Ccodegen-units=1 +build:ferrocene-coverage --@rules_rust//rust/settings:extra_rustc_flag=-Cdebuginfo=2 +build:ferrocene-coverage --@rules_rust//rust/settings:extra_exec_rustc_flag=-Cinstrument-coverage +build:ferrocene-coverage --@rules_rust//rust/settings:extra_exec_rustc_flag=-Clink-dead-code +build:ferrocene-coverage --@rules_rust//rust/settings:extra_exec_rustc_flag=-Ccodegen-units=1 +build:ferrocene-coverage --@rules_rust//rust/settings:extra_exec_rustc_flag=-Cdebuginfo=2 +test:ferrocene-coverage --run_under=@score_tooling//coverage:llvm_profile_wrapper +``` + +### 3) Add a repo-local wrapper target + +In a root `BUILD` file: + +```starlark +load("@score_tooling//coverage:coverage.bzl", "rust_coverage_report") + +rust_coverage_report( + name = "rust_coverage", + bazel_configs = [ + "ferrocene-x86_64-linux", + "ferrocene-coverage", + ], + query = 'kind("rust_test", //...)', + min_line_coverage = "80", +) +``` + +Run it with: + +```bash +bazel run //:rust_coverage +``` + +### 4) Optional: exclude known-problematic targets + +```starlark +query = 'kind("rust_test", //...) except //path/to:tests', +``` + +## Cross/Target Execution + +If tests run on target hardware, copy the `.profraw` files back to the host +and point the report generator to the directory: + +```bash +bazel run //:rust_coverage -- --profraw-dir /path/to/profraw +``` + +## Coverage Gate Behavior + +`--min-line-coverage` applies per target. If any target is below the minimum, +the script exits non-zero so CI can fail the job. An overall summary is printed +for visibility but does not change gating behavior. + +## Common Pitfalls + +- **"running 0 tests"**: The Rust test harness found no `#[test]` functions, + so coverage is 0%. Add tests or exclude the target from the query. +- **"couldn't find source file"** warnings: Usually path remapping or crate + mapping issues. Check that `crate` attributes in `rust_test` targets point to + the library crate (or exclude the target). +- **Cached test results**: Use `--nocache_test_results` if you need to re-run + tests and regenerate `.profraw` files. + +## Troubleshooting + +### Coverage is 0% but tests ran +- Verify the target contains real `#[test]` functions. A rust_test target with + no tests will run but report 0% coverage. +- Ensure you ran tests with `--config=ferrocene-coverage` so `.profraw` files + exist. +- If the test binary is cached, use `--nocache_test_results`. + +### "couldn't find source file" warnings +- Check `crate` mapping on `rust_test` targets. If `crate = "name"` is used, + ensure it refers to the library crate in the same package. +- Confirm the reported paths exist in the workspace. Path remapping is required + so `blanket` can resolve files under `--ferrocene-src`. + +### No `.profraw` files found +- Ensure `test:ferrocene-coverage` sets `--run_under=@score_tooling//coverage:llvm_profile_wrapper`. +- Re-run tests with `--nocache_test_results`. +- If tests ran on target hardware, copy the `.profraw` files back and pass + `--profraw-dir`. + +### Coverage gate fails in CI +- The gate is per-target. A single target below the threshold fails the job. +- Use a stricter query (exclude known-zero targets) or add tests. + +## CI Integration (Suggested Pattern) + +Keep coverage generation separate from docs: + +1) Coverage workflow: + - run `bazel run //:rust_coverage` + - upload `bazel-bin/coverage/rust-tests` as an artifact + +2) Docs workflow: + - download the artifact + - copy into the docs output (e.g. `docs/_static/coverage/`) + - publish Sphinx docs to GitHub Pages diff --git a/coverage/coverage.bzl b/coverage/coverage.bzl new file mode 100644 index 0000000..2d8e9b0 --- /dev/null +++ b/coverage/coverage.bzl @@ -0,0 +1,90 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Bazel helpers for Ferrocene Rust coverage workflows.""" + +def _shell_quote(value): + if value == "": + return "''" + return "'" + value.replace("'", "'\"'\"'") + "'" + +def _rust_coverage_report_impl(ctx): + script = ctx.actions.declare_file(ctx.label.name + ".sh") + + args = [] + for cfg in ctx.attr.bazel_configs: + if cfg: + args.extend(["--bazel-config", cfg]) + if ctx.attr.query: + args.extend(["--query", ctx.attr.query]) + if ctx.attr.min_line_coverage: + args.extend(["--min-line-coverage", ctx.attr.min_line_coverage]) + + args_parts = [_shell_quote(a) for a in args] + # The wrapper script forwards preconfigured args and any extra CLI args. + exec_line = "exec \"${ferrocene_report}\"" + if args_parts: + exec_line += " \\\n " + " \\\n ".join(args_parts) + exec_line += " \\\n \"$@\"" + + # Resolve the report script via runfiles for remote/CI compatibility. + runfile_path = ctx.executable._ferrocene_report.short_path + script_content = """#!/usr/bin/env bash +set -euo pipefail + +report_runfile={runfile} +if [[ -n "${{RUNFILES_DIR:-}}" ]]; then + ferrocene_report="${{RUNFILES_DIR}}/${{report_runfile}}" +elif [[ -n "${{RUNFILES_MANIFEST_FILE:-}}" ]]; then + ferrocene_report="$(grep -m1 "^${{report_runfile}} " "${{RUNFILES_MANIFEST_FILE}}" | cut -d' ' -f2-)" +else + ferrocene_report="${{report_runfile}}" +fi + +if [[ ! -x "${{ferrocene_report}}" ]]; then + echo "ferrocene_report not found at ${{ferrocene_report}}" >&2 + exit 1 +fi + +{exec_line} +""".format(runfile = _shell_quote(runfile_path), exec_line = exec_line) + + ctx.actions.write(script, script_content, is_executable = True) + + runfiles = ctx.runfiles(files = [ctx.executable._ferrocene_report]) + return [DefaultInfo(executable = script, runfiles = runfiles)] + +rust_coverage_report = rule( + implementation = _rust_coverage_report_impl, + executable = True, + attrs = { + "bazel_configs": attr.string_list( + default = ["ferrocene-coverage"], + doc = "Bazel configs passed to ferrocene_report.", + ), + "query": attr.string( + default = 'kind("rust_test", //...)', + doc = "Bazel query used to discover rust_test targets.", + ), + "min_line_coverage": attr.string( + default = "", + doc = "Optional minimum line coverage percentage.", + ), + "_ferrocene_report": attr.label( + default = Label("//coverage:ferrocene_report"), + executable = True, + cfg = "exec", + ), + }, + doc = "Creates a repo-local wrapper for Ferrocene Rust coverage reports.", +) diff --git a/coverage/ferrocene_report.sh b/coverage/ferrocene_report.sh new file mode 100755 index 0000000..11d089b --- /dev/null +++ b/coverage/ferrocene_report.sh @@ -0,0 +1,931 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Generate Ferrocene Rust coverage reports from Bazel rust_test targets. + +Usage: + bazel run @score_tooling//coverage:ferrocene_report -- [options] + +Options: + --query Bazel query for rust_test targets. + Default: kind("rust_test", //...) + --targets Comma-separated list of test labels (overrides --query). + --out-dir Output directory for reports. + Default: /coverage/rust-tests + --profraw-dir Directory containing .profraw files for all targets. + If set, used for every target. + --profraw-root Root for bazel-testlogs. Default: bazel info bazel-testlogs + --bazel-config Bazel config to use for build/cquery. + Repeat to pass multiple configs. + Default: ferrocene-coverage + --min-line-coverage

Minimum line coverage percentage (0-100). + If any target is below, exit non-zero. + --target-triple Rust target triple. Default: x86_64-unknown-linux-gnu + --ferrocene-repo Ferrocene repo name. Default: ferrocene_x86_64_unknown_linux_gnu + --help Show this help. + +Notes: +- Run tests first with: bazel test --config=ferrocene-coverage +- The config should set LLVM_PROFILE_FILE to TEST_UNDECLARED_OUTPUTS_DIR. +- For cross/target execution, copy .profraw files back and pass --profraw-dir. +USAGE +} + +QUERY='kind("rust_test", //...)' +TARGETS_CSV="" +OUT_DIR="" +PROFRAW_DIR="" +PROFRAW_ROOT="" +BAZEL_CONFIGS=() +TARGET_TRIPLE="x86_64-unknown-linux-gnu" +FERROCENE_REPO="ferrocene_x86_64_unknown_linux_gnu" +SYMBOL_REPORT_LABEL="" +BLANKET_LABEL="" +MIN_LINE_COVERAGE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --query) + QUERY="$2"; shift 2 ;; + --targets) + TARGETS_CSV="$2"; shift 2 ;; + --out-dir) + OUT_DIR="$2"; shift 2 ;; + --profraw-dir) + PROFRAW_DIR="$2"; shift 2 ;; + --profraw-root) + PROFRAW_ROOT="$2"; shift 2 ;; + --bazel-config) + BAZEL_CONFIGS+=("$2"); shift 2 ;; + --min-line-coverage) + MIN_LINE_COVERAGE="$2"; shift 2 ;; + --target-triple) + TARGET_TRIPLE="$2"; shift 2 ;; + --ferrocene-repo) + FERROCENE_REPO="$2"; shift 2 ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown arg: $1" >&2 + usage + exit 2 + ;; + esac + done + +workspace="${BUILD_WORKSPACE_DIRECTORY:-$(pwd)}" +cd "${workspace}" + +if [[ -z "${OUT_DIR}" ]]; then + # Keep reports under bazel-bin by default so they track the build output tree. + OUT_DIR="$(bazel info bazel-bin)/coverage/rust-tests" +fi + +if [[ ${#BAZEL_CONFIGS[@]} -eq 0 ]]; then + BAZEL_CONFIGS=("ferrocene-coverage") +fi + +BAZEL_FLAGS=() +for cfg in "${BAZEL_CONFIGS[@]}"; do + if [[ -n "${cfg}" ]]; then + BAZEL_FLAGS+=("--config=${cfg}") + fi +done + +if [[ -z "${SYMBOL_REPORT_LABEL}" ]]; then + SYMBOL_REPORT_LABEL="@score_toolchains_rust//toolchains/ferrocene:${FERROCENE_REPO}_symbol-report" +fi +if [[ -z "${BLANKET_LABEL}" ]]; then + BLANKET_LABEL="@score_toolchains_rust//toolchains/ferrocene:${FERROCENE_REPO}_blanket" +fi + +if [[ -z "${PROFRAW_ROOT}" ]]; then + PROFRAW_ROOT="$(bazel info bazel-testlogs)" +fi + +output_base="$(bazel info output_base)" +exec_root="$(bazel info execution_root)" + +cquery_expr() { + local label="$1" + local expr="$2" + bazel cquery "${BAZEL_FLAGS[@]}" --output=starlark --starlark:expr="${expr}" "${label}" +} + +query_attr_build() { + local label="$1" + local attr="$2" + local tmp + tmp="$(mktemp)" + bazel query --output=build "${label}" >"${tmp}" + python3 - "$attr" "${tmp}" <<'PY' +import re +import sys + +attr = sys.argv[1] +with open(sys.argv[2], "r", encoding="utf-8") as fh: + data = fh.read() + +# Match attr = on a single line. +m = re.search(r'\\b%s\\s*=\\s*([^\\n]+)' % re.escape(attr), data) +if not m: + sys.exit(0) +val = m.group(1).strip() + +# Trim trailing commas. +if val.endswith(","): + val = val[:-1].strip() + +# If list spans multiple lines, capture the first [...] block. +if val.startswith("[") and "]" not in val: + m = re.search(r'\\b%s\\s*=\\s*(\\[[^\\]]*\\])' % re.escape(attr), data, re.S) + if m: + val = m.group(1).strip() + +print(val) +PY + rm -f "${tmp}" +} + +query_labels_attr() { + local label="$1" + local attr="$2" + bazel query "labels(${attr}, ${label})" 2>/dev/null | head -n 1 +} + +aq_rustc_info() { + local label="$1" + local crate_root_rel="$2" + local exec_root="$3" + local tmp + tmp="$(mktemp)" + if ! bazel aquery "${BAZEL_FLAGS[@]}" --include_commandline --output=jsonproto "mnemonic(Rustc, ${label})" >"${tmp}" 2>/dev/null; then + rm -f "${tmp}" + return 0 + fi + python3 - "${crate_root_rel}" "${exec_root}" "${tmp}" <<'PY' +import json +import sys +import shlex +from pathlib import Path + +crate_root = sys.argv[1] +exec_root = sys.argv[2] +path = sys.argv[3] +try: + data = json.load(open(path, "r", encoding="utf-8")) +except Exception: + sys.exit(0) + +actions = data.get("actions", []) +if not actions: + sys.exit(0) + +def match_action(act): + args = act.get("arguments", []) + if crate_root and crate_root in args: + return True + if crate_root: + for arg in args: + if arg.endswith(crate_root): + return True + return False + +action = None +for act in actions: + if match_action(act): + action = act + break +if action is None: + action = actions[0] + +args = list(action.get("arguments", []) or []) + +def resolve_path(p: Path) -> Path: + if p.is_absolute(): + return p + if exec_root: + return Path(exec_root) / p + return p + +def expand_params(argv): + expanded = [] + for arg in argv: + if arg.startswith("@") and len(arg) > 1: + p = resolve_path(Path(arg[1:])) + if p.is_file(): + content = p.read_text(encoding="utf-8", errors="ignore") + try: + expanded.extend(shlex.split(content)) + except ValueError: + expanded.extend([line for line in content.splitlines() if line.strip()]) + continue + expanded.append(arg) + return expanded + +args = expand_params(args) + +if "--" in args: + args = args[args.index("--") + 1:] + +link_output = "" +for a in args: + if a.startswith("--emit=link="): + link_output = a.split("=", 2)[2] + break + +out = [] +i = 0 +while i < len(args): + a = args[i] + if a == "--extern" and i + 1 < len(args): + out.extend(["--extern", args[i + 1]]) + i += 2 + continue + if a.startswith("--extern="): + out.append(a) + i += 1 + continue + if a == "-L" and i + 1 < len(args): + out.extend(["-L", args[i + 1]]) + i += 2 + continue + if a.startswith("-L"): + out.append(a) + i += 1 + continue + if a == "--cfg" and i + 1 < len(args): + out.extend(["--cfg", args[i + 1]]) + i += 2 + continue + if a.startswith("--cfg="): + out.append(a) + i += 1 + continue + if a == "--test": + out.append(a) + i += 1 + continue + i += 1 + +lines = [link_output] + out +sys.stdout.write("\n".join(lines)) +PY + rm -f "${tmp}" +} + +tool_cquery_expr() { + local label="$1" + local expr="$2" + bazel cquery --output=starlark --starlark:expr="${expr}" "${label}" +} + +# Build and locate the coverage tool wrappers (host tools; avoid target platform flags). +bazel build "${SYMBOL_REPORT_LABEL}" "${BLANKET_LABEL}" >/dev/null + +symbol_report_rel="$(tool_cquery_expr "${SYMBOL_REPORT_LABEL}" 'target.files.to_list()[0].path')" +blanket_rel="$(tool_cquery_expr "${BLANKET_LABEL}" 'target.files.to_list()[0].path')" +if [[ -z "${symbol_report_rel}" || -z "${blanket_rel}" ]]; then + echo "Failed to resolve coverage tool wrapper paths." >&2 + exit 1 +fi + +if [[ "${symbol_report_rel}" == /* ]]; then + symbol_report_bin="${symbol_report_rel}" +else + symbol_report_bin="${exec_root}/${symbol_report_rel}" +fi +if [[ "${blanket_rel}" == /* ]]; then + blanket_bin="${blanket_rel}" +else + blanket_bin="${exec_root}/${blanket_rel}" +fi + +resolve_realpath() { + local path="$1" + if command -v realpath >/dev/null 2>&1; then + realpath "${path}" + return 0 + fi + if command -v readlink >/dev/null 2>&1; then + readlink -f "${path}" + return 0 + fi + echo "${path}" +} + +strip_quotes() { + local v="$1" + v="${v%\"}" + v="${v#\"}" + echo "${v}" +} + +label_to_path() { + local label + local pkg="${2:-}" + label="$(strip_quotes "$1")" + if [[ "${label}" == @* ]]; then + echo "" + return 0 + fi + if [[ "${label}" == :* ]]; then + if [[ -n "${pkg}" ]]; then + echo "${pkg}/${label#:}" + else + echo "${label#:}" + fi + return 0 + fi + if [[ "${label}" == //* ]]; then + local rest="${label#//}" + local pkg="${rest%%:*}" + if [[ "${rest}" == *:* ]]; then + local name="${rest#*:}" + if [[ -n "${pkg}" ]]; then + echo "${pkg}/${name}" + else + echo "${name}" + fi + else + echo "${pkg}" + fi + return 0 + fi + echo "${label}" +} + +normalize_scalar() { + local v + v="$(strip_quotes "$1")" + if [[ "${v}" =~ ^Label\\(\"(.*)\"\\)$ ]]; then + v="${BASH_REMATCH[1]}" + fi + if [[ "${v}" == \[*\] ]]; then + v="${v#[}" + v="${v%]}" + v="$(strip_quotes "${v%%,*}")" + fi + echo "${v}" +} + +normalize_label() { + local label + local pkg="${2:-}" + label="$(strip_quotes "$1")" + if [[ "${label}" =~ ^Label\\(\"(.*)\"\\)$ ]]; then + label="${BASH_REMATCH[1]}" + fi + if [[ "${label}" == :* ]]; then + if [[ -n "${pkg}" ]]; then + echo "//${pkg}:${label#:}" + else + echo "${label#:}" + fi + return 0 + fi + echo "${label}" +} + +label_pkg() { + local label + label="$(strip_quotes "$1")" + if [[ "${label}" =~ ^Label\\(\"(.*)\"\\)$ ]]; then + label="${BASH_REMATCH[1]}" + fi + if [[ "${label}" == //* ]]; then + local rest="${label#//}" + echo "${rest%%:*}" + return 0 + fi + echo "" +} + +resolve_runfile() { + local bin="$1" + local name="$2" + local runfiles_dir="" + + if [[ -d "${bin}.runfiles" ]]; then + runfiles_dir="${bin}.runfiles" + elif [[ -f "${bin}.runfiles_manifest" ]]; then + local entry + entry="$(grep -m1 "/${name}$" "${bin}.runfiles_manifest" || true)" + if [[ -n "${entry}" ]]; then + echo "${entry#* }" + return 0 + fi + fi + + if [[ -z "${runfiles_dir}" ]]; then + return 1 + fi + + find "${runfiles_dir}" -type f -name "${name}" -print -quit 2>/dev/null +} + +# Derive the Ferrocene sysroot from the wrapper location. +if [[ -z "${SYSROOT:-}" ]]; then + symbol_report_real="$(resolve_realpath "${symbol_report_bin}" || true)" + if [[ -n "${symbol_report_real}" && "${symbol_report_real}" == */symbol-report.sh ]]; then + SYSROOT="$(cd "$(dirname "${symbol_report_real}")" && pwd)" + else + symbol_report_runfile="$(resolve_runfile "${symbol_report_bin}" "symbol-report.sh" || true)" + if [[ -z "${symbol_report_runfile}" ]]; then + echo "Failed to locate symbol-report.sh in runfiles for ${symbol_report_bin}" >&2 + exit 1 + fi + SYSROOT="$(cd "$(dirname "${symbol_report_runfile}")" && pwd)" + fi +fi + +prefer_wrapper_script() { + local bin="$1" + local name="$2" + if [[ "${bin}" == */${name} ]]; then + local candidate + candidate="$(dirname "${bin}")/${name}.sh" + if [[ -f "${candidate}" ]]; then + echo "${candidate}" + return 0 + fi + fi + echo "${bin}" +} + +symbol_report_bin="$(prefer_wrapper_script "${symbol_report_bin}" "symbol-report")" +blanket_bin="$(prefer_wrapper_script "${blanket_bin}" "blanket")" + +raw_binary_path() { + local bin="$1" + if [[ "${bin}" == *.sh ]]; then + local raw="${bin%.sh}" + if [[ -x "${raw}" ]]; then + echo "${raw}" + return 0 + fi + fi + echo "${bin}" +} + +extra_ld_for_missing_driver() { + local bin="$1" + local sysroot="$2" + if [[ ! -x "${bin}" ]]; then + echo "" + return 0 + fi + local missing + missing="$(ldd "${bin}" 2>/dev/null | awk '/librustc_driver-.*not found/ {print $1; exit}')" + if [[ -z "${missing}" ]]; then + echo "" + return 0 + fi + local existing + existing="$(ls "${sysroot}"/lib/librustc_driver-*.so 2>/dev/null | head -n1 || true)" + if [[ -z "${existing}" ]]; then + echo "" + return 0 + fi + local tmpdir + tmpdir="$(mktemp -d)" + ln -s "${existing}" "${tmpdir}/${missing}" + echo "${tmpdir}" +} + +symbol_report_cmd=("${symbol_report_bin}") +if [[ ! -x "${symbol_report_bin}" ]]; then + if [[ "${symbol_report_bin}" == *.sh && -f "${symbol_report_bin}" ]]; then + symbol_report_cmd=(bash "${symbol_report_bin}") + else + echo "symbol-report wrapper not executable at ${symbol_report_bin}" >&2 + exit 1 + fi +fi + +blanket_cmd=("${blanket_bin}") +if [[ ! -x "${blanket_bin}" ]]; then + if [[ "${blanket_bin}" == *.sh && -f "${blanket_bin}" ]]; then + blanket_cmd=(bash "${blanket_bin}") + else + echo "blanket wrapper not executable at ${blanket_bin}" >&2 + exit 1 + fi +fi + +symbol_report_raw="$(raw_binary_path "${symbol_report_bin}")" +blanket_raw="$(raw_binary_path "${blanket_bin}")" +symbol_report_extra_ld="$(extra_ld_for_missing_driver "${symbol_report_raw}" "${SYSROOT}")" +blanket_extra_ld="$(extra_ld_for_missing_driver "${blanket_raw}" "${SYSROOT}")" + +mapfile -t targets < <( + if [[ -n "${TARGETS_CSV}" ]]; then + echo "${TARGETS_CSV}" | tr ',' '\n' + else + bazel query "${QUERY}" + fi +) + +if [[ ${#targets[@]} -eq 0 ]]; then + echo "No targets found for query: ${QUERY}" >&2 + exit 1 +fi + +mkdir -p "${OUT_DIR}" + +parse_line_coverage() { + local html_path="$1" + # Blanket reports line coverage in the HTML summary; parse it for gating. + python3 - "${html_path}" <<'PY' +import re +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +try: + text = path.read_text(encoding="utf-8", errors="ignore") +except FileNotFoundError: + sys.exit(1) + +m = re.search(r'([0-9]+(?:\.[0-9]+)?)%\s*\((\d+)/(\d+)\s+lines\)', text) +if not m: + sys.exit(2) + +print(f"{m.group(1)} {m.group(2)} {m.group(3)}") +PY +} + +failures=() +total_covered=0 +total_lines=0 +parsed_targets=0 + +for label in "${targets[@]}"; do + if [[ -z "${label}" ]]; then + continue + fi + + pkg="${label#//}" + pkg="${pkg%%:*}" + name="${label##*:}" + + if [[ -n "${PROFRAW_DIR}" ]]; then + test_out_dir="${PROFRAW_DIR}" + else + test_out_dir="${PROFRAW_ROOT}/${pkg}/${name}/test.outputs" + fi + + shopt -s nullglob + profraw_files=("${test_out_dir}"/*.profraw) + shopt -u nullglob + + if [[ ${#profraw_files[@]} -eq 0 ]]; then + echo "Skipping ${label}: no .profraw files in ${test_out_dir}" >&2 + continue + fi + + # Build the test target with the chosen config to ensure the instrumented binary exists. + bazel build "${BAZEL_FLAGS[@]}" "${label}" >/dev/null + + bin_rel="$(cquery_expr "${label}" 'target.files.to_list()[0].path')" + if [[ -z "${bin_rel}" ]]; then + echo "Skipping ${label}: could not resolve test binary path" >&2 + continue + fi + + if [[ "${bin_rel}" == /* ]]; then + bin_path="${bin_rel}" + else + bin_path="${exec_root}/${bin_rel}" + fi + + if [[ ! -x "${bin_path}" ]]; then + echo "Skipping ${label}: test binary not found at ${bin_path}" >&2 + continue + fi + + # rust_test can reference a crate by label (preferred) or by name. + # We try label first, then fall back to the raw attribute. + crate_label_raw="$(query_labels_attr "${label}" "crate")" + if [[ -z "${crate_label_raw}" ]]; then + crate_label_raw="$(query_attr_build "${label}" "crate")" + fi + crate_label="$(normalize_scalar "${crate_label_raw}")" + crate_type="" + crate_target="" + if [[ -n "${crate_label}" ]]; then + crate_target="$(normalize_label "${crate_label}" "${pkg}")" + crate_type="lib" + else + crate_target="${label}" + crate_type="$(normalize_scalar "$(query_attr_build "${label}" "crate_type")")" + fi + if [[ -z "${crate_type}" ]]; then + crate_type="bin" + fi + + crate_pkg="$(label_pkg "${crate_target}")" + if [[ -z "${crate_pkg}" ]]; then + crate_pkg="${pkg}" + fi + + crate_root_raw="$(query_labels_attr "${crate_target}" "crate_root")" + if [[ -z "${crate_root_raw}" ]]; then + crate_root_raw="$(query_attr_build "${crate_target}" "crate_root")" + fi + crate_root="$(label_to_path "${crate_root_raw}" "${crate_pkg}")" + if [[ -z "${crate_root}" ]]; then + # Prefer explicit srcs for rust_test targets when no crate attribute is set. + srcs_label="$(query_labels_attr "${label}" "srcs")" + if [[ -n "${srcs_label}" ]]; then + srcs_path="$(label_to_path "${srcs_label}" "${pkg}")" + if [[ -n "${srcs_path}" && "${srcs_path}" == *.rs ]]; then + crate_root="${srcs_path}" + fi + fi + fi + if [[ -z "${crate_root}" ]]; then + for candidate in \ + "${crate_pkg}/src/lib.rs" \ + "${crate_pkg}/src/main.rs" \ + "${crate_pkg}/lib.rs" \ + "${crate_pkg}/main.rs"; do + if [[ -f "${workspace}/${candidate}" ]]; then + crate_root="${candidate}" + break + fi + done + if [[ -z "${crate_root}" ]]; then + echo "Skipping ${label}: could not determine crate root for ${crate_target}" >&2 + continue + fi + fi + + if [[ "${crate_root}" != /* ]]; then + crate_root="${workspace}/${crate_root}" + fi + + crate_root_rel="${crate_root}" + if [[ "${crate_root_rel}" == "${workspace}/"* ]]; then + crate_root_rel="${crate_root_rel#${workspace}/}" + fi + + crate_name="$(normalize_scalar "$(query_attr_build "${crate_target}" "crate_name")")" + if [[ -z "${crate_name}" ]]; then + crate_name="${crate_target##*:}" + crate_name="${crate_name#//}" + fi + edition="$(normalize_scalar "$(query_attr_build "${crate_target}" "edition")")" + if [[ -z "${edition}" ]]; then + edition="2021" + fi + + mapfile -t aquery_lines < <(aq_rustc_info "${crate_target}" "${crate_root_rel}" "${exec_root}") + link_out="${aquery_lines[0]:-}" + extra_rustc_flags=("${aquery_lines[@]:1}") + test_link_out="" + if [[ "${crate_target}" != "${label}" ]]; then + mapfile -t test_aquery_lines < <(aq_rustc_info "${label}" "${crate_root_rel}" "${exec_root}") + test_link_out="${test_aquery_lines[0]:-}" + fi + extra_rustc_args=() + for flag in "${extra_rustc_flags[@]}"; do + if [[ "${flag}" == --extern=* ]]; then + extra_rustc_args+=("--extern" "${flag#--extern=}") + elif [[ "${flag}" == --cfg=* ]]; then + extra_rustc_args+=("--cfg" "${flag#--cfg=}") + elif [[ "${flag}" == --test ]]; then + extra_rustc_args+=("--test") + else + extra_rustc_args+=("${flag}") + fi + done + if [[ "${FERROCENE_REPORT_DEBUG:-0}" == "1" ]]; then + echo "Debug: ${label} crate_label_raw=${crate_label_raw}" >&2 + echo "Debug: ${label} crate_label=${crate_label}" >&2 + echo "Debug: ${label} crate_target=${crate_target} crate_type=${crate_type} crate_name=${crate_name} edition=${edition}" >&2 + echo "Debug: ${label} link_out=${link_out}" >&2 + if [[ -n "${test_link_out}" ]]; then + echo "Debug: ${label} test_link_out=${test_link_out}" >&2 + fi + echo "Debug: ${label} crate_root=${crate_root_rel} flags from aquery:" >&2 + printf ' %s\n' "${extra_rustc_flags[@]}" >&2 + echo "Debug: ${label} normalized rustc args:" >&2 + printf ' %q\n' "${extra_rustc_args[@]}" >&2 + fi + + if [[ -n "${link_out}" ]]; then + if [[ "${link_out}" == /* ]]; then + candidate_path="${link_out}" + else + candidate_path="${exec_root}/${link_out}" + fi + if [[ -x "${candidate_path}" ]]; then + bin_rel="${link_out}" + bin_path="${candidate_path}" + fi + fi + if [[ -n "${test_link_out}" ]]; then + if [[ "${test_link_out}" == /* ]]; then + candidate_path="${test_link_out}" + else + candidate_path="${exec_root}/${test_link_out}" + fi + if [[ -x "${candidate_path}" ]]; then + bin_rel="${test_link_out}" + bin_path="${candidate_path}" + fi + fi + + safe_label="${label//\//_}" + safe_label="${safe_label//:/_}" + safe_label="${safe_label//@/_}" + + report_dir="${OUT_DIR}/${safe_label}" + mkdir -p "${report_dir}" + + symbol_report_json="${report_dir}/symbol-report.json" + + sysroot_arg="${SYSROOT}" + if [[ "${SYSROOT}" == "${exec_root}/"* ]]; then + sysroot_arg="${SYSROOT#${exec_root}/}" + fi + + # Remap execroot/workspace paths to keep symbol-report filenames stable. + remap_args=() + if [[ -n "${exec_root}" ]]; then + remap_args+=("--remap-path-prefix=${exec_root}/=.") + fi + if [[ "${workspace}" != "${exec_root}" ]]; then + remap_args+=("--remap-path-prefix=${workspace}/=.") + fi + + ( + cd "${exec_root}" + SYMBOL_REPORT_OUT="${symbol_report_json}" \ + LD_LIBRARY_PATH="${symbol_report_extra_ld}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ + "${symbol_report_cmd[@]}" \ + --crate-name "${crate_name}" \ + --edition "${edition}" \ + --crate-type "${crate_type}" \ + "${extra_rustc_args[@]}" \ + --target "${TARGET_TRIPLE}" \ + --sysroot "${sysroot_arg}" \ + -o /dev/null \ + "${remap_args[@]}" \ + "${crate_root_rel}" + ) + + # Normalize symbol-report paths to be workspace-relative (like the demo), + # so blanket can reliably locate sources. + python3 - "${symbol_report_json}" "${workspace}" "${exec_root}" <<'PY' +import json +import sys +from pathlib import Path + +json_path = Path(sys.argv[1]) +roots = [] +for arg in sys.argv[2:]: + if arg: + roots.append(Path(arg).resolve()) + +with json_path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + +def relativize(p: Path): + if not p.is_absolute(): + return p.as_posix() + for root in roots: + try: + return p.relative_to(root).as_posix() + except ValueError: + pass + try: + rp = p.resolve() + except Exception: + return None + for root in roots: + try: + return rp.relative_to(root).as_posix() + except ValueError: + pass + return None + +changed = False +symbols = [] +for sym in data.get("symbols", []): + fname = sym.get("filename") + if not fname: + continue + rel = relativize(Path(fname)) + if rel is None: + changed = True + continue + if rel != fname: + sym["filename"] = rel + changed = True + symbols.append(sym) + +if symbols != data.get("symbols", []): + data["symbols"] = symbols + +if changed: + with json_path.open("w", encoding="utf-8") as fh: + json.dump(data, fh) +PY + + bin_arg="${bin_path}" + if [[ "${bin_rel}" != /* ]]; then + bin_arg="${bin_rel}" + fi + + # Blanket expects report paths to resolve under --ferrocene-src; add a + # path-equivalence so workspace files map cleanly to report entries. + ferrocene_src="${workspace}" + crate_root_dir_rel="$(dirname "${crate_root_rel}")" + path_prefix="${crate_root_rel%%/*}" + if [[ -n "${path_prefix}" && "${path_prefix}" != "${crate_root_rel}" && "${path_prefix}" != "." ]]; then + # Broader remap to cover any file under the top-level directory (e.g. src/...). + path_equiv_args=("--path-equivalence" "${path_prefix},${workspace}/${path_prefix}") + elif [[ "${crate_root_dir_rel}" == "." ]]; then + path_equiv_args=("--path-equivalence" ".,${workspace}") + else + path_equiv_args=("--path-equivalence" "${crate_root_dir_rel},${workspace}/${crate_root_dir_rel}") + fi + + ( + cd "${workspace}" + LD_LIBRARY_PATH="${blanket_extra_ld}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ + "${blanket_cmd[@]}" show \ + $(printf -- '--instr-profile=%s ' "${profraw_files[@]}") \ + --object "${bin_arg}" \ + --report "${symbol_report_json}" \ + --ferrocene-src "${ferrocene_src}" \ + "${path_equiv_args[@]}" \ + --html-out "${report_dir}/blanket/index.html" + ) + + line_cov="" + line_cov_info="" + if line_cov="$(parse_line_coverage "${report_dir}/blanket/index.html" 2>/dev/null)"; then + read -r line_pct line_cov_lines line_total_lines <<<"${line_cov}" + line_cov_info="${line_pct}% (${line_cov_lines}/${line_total_lines} lines)" + echo "Line coverage for ${label}: ${line_cov_info}" + if [[ -n "${line_cov_lines}" && -n "${line_total_lines}" ]]; then + total_covered=$((total_covered + line_cov_lines)) + total_lines=$((total_lines + line_total_lines)) + parsed_targets=$((parsed_targets + 1)) + fi + if [[ -n "${MIN_LINE_COVERAGE}" ]]; then + if ! python3 - "${line_pct}" "${MIN_LINE_COVERAGE}" <<'PY' +import sys +try: + val = float(sys.argv[1]) + minv = float(sys.argv[2]) +except ValueError: + sys.exit(0) +sys.exit(0 if val >= minv else 1) +PY + then + failures+=("${label} (${line_cov_info})") + fi + fi + else + echo "Warning: could not parse line coverage from ${report_dir}/blanket/index.html" >&2 + if [[ -n "${MIN_LINE_COVERAGE}" ]]; then + failures+=("${label} (no line coverage parsed)") + fi + fi + + echo "Report for ${label}: ${report_dir}/blanket/index.html" + done + +if [[ ${total_lines} -gt 0 ]]; then + # Aggregate line coverage across all targets that parsed successfully. + overall_pct="$(python3 - "${total_covered}" "${total_lines}" <<'PY' +import sys +cov = int(sys.argv[1]) +total = int(sys.argv[2]) +if total == 0: + print("0.00") +else: + print(f"{(cov / total) * 100:.2f}") +PY +)" + echo "---" + echo "Overall line coverage: ${overall_pct}% (${total_covered}/${total_lines} lines across ${parsed_targets} targets)" + echo "---" +else + echo "---" + echo "Overall line coverage: n/a (no per-target line coverage parsed)" + echo "---" +fi + +if [[ ${#failures[@]} -gt 0 ]]; then + # Fail CI when any target is below the minimum line-coverage threshold. + echo "Line coverage gate failed (min ${MIN_LINE_COVERAGE}%):" >&2 + printf ' %s\n' "${failures[@]}" >&2 + exit 3 +fi diff --git a/coverage/llvm_profile_wrapper.sh b/coverage/llvm_profile_wrapper.sh new file mode 100755 index 0000000..3887c36 --- /dev/null +++ b/coverage/llvm_profile_wrapper.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wrapper for Bazel tests: set LLVM_PROFILE_FILE to TEST_UNDECLARED_OUTPUTS_DIR +# so .profraw files are collected into test.outputs. + +if [[ -n "${TEST_UNDECLARED_OUTPUTS_DIR:-}" ]]; then + mkdir -p "${TEST_UNDECLARED_OUTPUTS_DIR}" + export LLVM_PROFILE_FILE="${TEST_UNDECLARED_OUTPUTS_DIR}/%p.profraw" +fi + +exec "$@" diff --git a/defs.bzl b/defs.bzl index 021fc66..3ed20ed 100644 --- a/defs.bzl +++ b/defs.bzl @@ -16,6 +16,8 @@ # --- cli_helper --- load("//cli_helper:cli_helper.bzl", _cli_helper = "cli_helper") +# --- coverage --- +load("//coverage:coverage.bzl", _rust_coverage_report = "rust_coverage_report") # --- cr_checker --- load("//cr_checker:cr_checker.bzl", _copyright_checker = "copyright_checker") @@ -42,3 +44,4 @@ copyright_checker = _copyright_checker cli_helper = _cli_helper use_format_targets = _use_format_targets setup_starpls = _setup_starpls +rust_coverage_report = _rust_coverage_report diff --git a/tools/README.md b/tools/README.md index 6452693..a2f6973 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,6 +1,9 @@ -# S-CORE linter +# S-CORE tooling binaries -This bazel module provides centrally managed binaries for linter and static analysis tools used in S-CORE. +This bazel module provides centrally managed binaries for developer tools used in S-CORE, including linters +and formatters. + +For Rust coverage reporting, see [coverage/README.md](../coverage/README.md). It provides a template script `sample.sh` that can be adapted to run the tools you need. @@ -38,4 +41,4 @@ Adapt it to only run the tools you need, by deleting or commenting out the lines Ensure the script is executable `chmod u+x \ No newline at end of file diff --git a/coverage/tests/fixtures/symbol_report.json b/coverage/tests/fixtures/symbol_report.json new file mode 100644 index 0000000..7766b58 --- /dev/null +++ b/coverage/tests/fixtures/symbol_report.json @@ -0,0 +1,7 @@ +{ + "symbols": [ + {"filename": "/workspace/src/lib.rs"}, + {"filename": "/other/location/ignored.rs"}, + {"filename": "src/rel.rs"} + ] +} diff --git a/coverage/tests/normalize_symbol_report_test.sh b/coverage/tests/normalize_symbol_report_test.sh new file mode 100755 index 0000000..214c26e --- /dev/null +++ b/coverage/tests/normalize_symbol_report_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +fixture="${TEST_SRCDIR}/${TEST_WORKSPACE}/coverage/tests/fixtures/symbol_report.json" +script="${TEST_SRCDIR}/${TEST_WORKSPACE}/coverage/scripts/normalize_symbol_report.py" + +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT + +cp "${fixture}" "${workdir}/symbol_report.json" + +python3 "${script}" "${workdir}/symbol_report.json" "/workspace" "/execroot" + +python3 - "${workdir}/symbol_report.json" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +files = sorted({s.get("filename") for s in data.get("symbols", [])}) +expected = ["src/lib.rs", "src/rel.rs"] +if files != expected: + raise SystemExit(f"unexpected filenames: {files}") + +print("ok") +PY diff --git a/coverage/tests/parse_line_coverage_test.sh b/coverage/tests/parse_line_coverage_test.sh new file mode 100755 index 0000000..352ca6f --- /dev/null +++ b/coverage/tests/parse_line_coverage_test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +fixture="${TEST_SRCDIR}/${TEST_WORKSPACE}/coverage/tests/fixtures/blanket_index.html" +parser="${TEST_SRCDIR}/${TEST_WORKSPACE}/coverage/scripts/parse_line_coverage.py" + +output="$(python3 "${parser}" "${fixture}")" + +if [[ "${output}" != "100.00 8 8" ]]; then + echo "unexpected coverage summary: ${output}" >&2 + exit 1 +fi + +echo "ok" diff --git a/format_checker/BUILD b/format_checker/BUILD index cd77f26..60f8be7 100644 --- a/format_checker/BUILD +++ b/format_checker/BUILD @@ -11,6 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") + package(default_visibility = ["//visibility:public"]) sh_binary( diff --git a/tools/BUILD b/tools/BUILD index 4adc207..ec8f248 100644 --- a/tools/BUILD +++ b/tools/BUILD @@ -11,6 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") + package(default_visibility = ["//visibility:public"]) exports_files([ From 225a4a04b796f83279b5ce65a01b1fd0b898eb7d Mon Sep 17 00:00:00 2001 From: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:52:00 +0200 Subject: [PATCH 4/4] format: fix Fixing formatting issues Signed-off-by: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com> --- coverage/scripts/normalize_symbol_report.py | 7 ++++++- coverage/scripts/parse_line_coverage.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/coverage/scripts/normalize_symbol_report.py b/coverage/scripts/normalize_symbol_report.py index 804aa04..b3b653e 100755 --- a/coverage/scripts/normalize_symbol_report.py +++ b/coverage/scripts/normalize_symbol_report.py @@ -4,7 +4,10 @@ from pathlib import Path if len(sys.argv) < 2: - print("usage: normalize_symbol_report.py [roots...]", file=sys.stderr) + print( + "usage: normalize_symbol_report.py [roots...]", + file=sys.stderr, + ) sys.exit(2) json_path = Path(sys.argv[1]) @@ -16,6 +19,7 @@ with json_path.open("r", encoding="utf-8") as fh: data = json.load(fh) + def relativize(p: Path): if not p.is_absolute(): return p.as_posix() @@ -35,6 +39,7 @@ def relativize(p: Path): pass return None + changed = False symbols = [] for sym in data.get("symbols", []): diff --git a/coverage/scripts/parse_line_coverage.py b/coverage/scripts/parse_line_coverage.py index 1b40cee..1421afd 100755 --- a/coverage/scripts/parse_line_coverage.py +++ b/coverage/scripts/parse_line_coverage.py @@ -13,7 +13,7 @@ except FileNotFoundError: sys.exit(1) -m = re.search(r'([0-9]+(?:\.[0-9]+)?)%\s*\((\d+)/(\d+)\s+lines\)', text) +m = re.search(r"([0-9]+(?:\.[0-9]+)?)%\s*\((\d+)/(\d+)\s+lines\)", text) if not m: sys.exit(2)