diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 6dd11c614..cadad5775 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -22,6 +22,10 @@ on: branches: [main] paths: - 'docs/**' + - 'tools/generate_code_analysis.py' + - 'tools/BUILD' + - 'quality/coverage.bazelrc' + - 'quality/static_analysis/**' - '.github/workflows/deploy_docs.yml' workflow_dispatch: @@ -73,6 +77,23 @@ jobs: echo "should_deploy=true" >> "$GITHUB_OUTPUT" fi + - name: Install lcov + run: | + sudo apt-get update + sudo apt-get install -y lcov + + - name: Generate Coverage Report + run: | + bazel run //tools:generate_code_analysis -- --coverage + + - name: Generate CodeQL Report + run: | + bazel run //tools:generate_code_analysis -- --codeql + + - name: Generate Clang-Tidy Report + run: | + bazel run //tools:generate_code_analysis -- --clang-tidy + - name: Build Sphinx documentation env: DOCS_BASE_URL: "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}" @@ -149,6 +170,24 @@ jobs: fi done + # Inject shared CSS/JS into old versions that lack them + REPO_NAME="${{ github.event.repository.name }}" + CUSTOM_CSS="" + FLYOUT_CSS="" + FLYOUT_JS="" + for dir in publish/v*/; do + [ -d "$dir" ] || continue + while IFS= read -r -d '' f; do + if ! grep -q 'default_custom' "$f"; then + sed -i "s||${CUSTOM_CSS}\n|" "$f" + fi + if ! grep -q 'version_flyout' "$f"; then + sed -i "s||${FLYOUT_CSS}\n|" "$f" + sed -i "s||${FLYOUT_JS}\n|" "$f" + fi + done < <(find "$dir" -name '*.html' -print0) + done + # stable = newest tagged version that has docs if [[ "${IS_TAG}" == "true" ]]; then rm -rf publish/stable @@ -163,6 +202,7 @@ jobs: # Shared assets mkdir -p publish/_shared/css publish/_shared/js + cp docs/sphinx/_static/css/default_custom.css publish/_shared/css/ cp docs/sphinx/_static/css/version_flyout.css publish/_shared/css/ cp docs/sphinx/_static/js/version_flyout.js publish/_shared/js/ diff --git a/bazel/toolchains/template/conf.template.py b/bazel/toolchains/template/conf.template.py index ae8c80deb..fdfaf91ab 100644 --- a/bazel/toolchains/template/conf.template.py +++ b/bazel/toolchains/template/conf.template.py @@ -73,6 +73,11 @@ # -- Options for HTML output -- html_theme = 'pydata_sphinx_theme' +html_static_path = ["_static"] +html_css_files = [ + "css/default_custom.css", +] +html_js_files = [] # Professional theme configuration inspired by modern open-source projects html_theme_options = { diff --git a/docs/sphinx/BUILD b/docs/sphinx/BUILD index b0267a098..314a76d77 100644 --- a/docs/sphinx/BUILD +++ b/docs/sphinx/BUILD @@ -26,7 +26,7 @@ sphinx_docs_library( ) # Static assets for Sphinx documentation -filegroup( +sphinx_docs_library( name = "static_assets", srcs = glob(["_static/**/*"]), ) @@ -52,6 +52,10 @@ sphinx_module( testonly = True, srcs = [ + "code_analysis.rst", + "code_analysis/clang_tidy.rst", + "code_analysis/codeql.rst", + "code_analysis/coverage.rst", "how_to_document.rst", "index.rst", "introduction.rst", @@ -59,9 +63,9 @@ sphinx_module( "safety_reports.rst", ":doxygen_xml", ":generate_api_rst", - ":static_assets", ], docs_library_deps = [ + ":static_assets", "//score/mw/com:readme_md", ], exec_compatible_with = ["@platforms//os:linux"], diff --git a/docs/sphinx/_static/codeanalysis/.gitignore b/docs/sphinx/_static/codeanalysis/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/docs/sphinx/_static/codeanalysis/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/docs/sphinx/code_analysis.rst b/docs/sphinx/code_analysis.rst new file mode 100644 index 000000000..08b96b0f6 --- /dev/null +++ b/docs/sphinx/code_analysis.rst @@ -0,0 +1,15 @@ +Code Analysis Reports +===================== + +This section links to locally generated code-quality reports for coverage, +CodeQL, and clang-tidy. + +.. note:: + Run ``tools/generate_code_analysis.sh`` to generate reports locally. + +.. toctree:: + :maxdepth: 1 + + code_analysis/coverage + code_analysis/codeql + code_analysis/clang_tidy diff --git a/docs/sphinx/code_analysis/clang_tidy.rst b/docs/sphinx/code_analysis/clang_tidy.rst new file mode 100644 index 000000000..7a4f13c2c --- /dev/null +++ b/docs/sphinx/code_analysis/clang_tidy.rst @@ -0,0 +1,14 @@ +Clang-Tidy Report +================= + +clang-tidy analysis runs via ``bazel test --config=clang-tidy`` and its lint +outputs are collected into an HTML summary. + +.. note:: + Run ``tools/generate_code_analysis.sh --clang-tidy`` to generate reports locally. + +Generated HTML report: + +`Open clang-tidy report <../_static/codeanalysis/clang_tidy/index.html>`_ + +If the report has not been generated yet, the link points to a placeholder page. diff --git a/docs/sphinx/code_analysis/codeql.rst b/docs/sphinx/code_analysis/codeql.rst new file mode 100644 index 000000000..8e26afdb5 --- /dev/null +++ b/docs/sphinx/code_analysis/codeql.rst @@ -0,0 +1,14 @@ +CodeQL Report +============= + +CodeQL analysis runs via ``//quality/static_analysis:codeql_lint`` and produces +SARIF output that is converted to an HTML summary. + +.. note:: + Run ``tools/generate_code_analysis.sh --codeql`` to generate reports locally. + +Generated HTML report: + +`Open CodeQL report <../_static/codeanalysis/codeql/index.html>`_ + +If the report has not been generated yet, the link points to a placeholder page. diff --git a/docs/sphinx/code_analysis/coverage.rst b/docs/sphinx/code_analysis/coverage.rst new file mode 100644 index 000000000..a578e85ac --- /dev/null +++ b/docs/sphinx/code_analysis/coverage.rst @@ -0,0 +1,14 @@ +Coverage Report +=============== + +Coverage is generated from ``bazel coverage //...`` and rendered with +``genhtml``. + +.. note:: + Run ``tools/generate_code_analysis.sh --coverage`` to generate reports locally. + +Generated HTML report: + +`Open coverage report <../_static/codeanalysis/coverage/index.html>`_ + +If the report has not been generated yet, the link points to a placeholder page. diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst index 1d6e7ba66..5dd8ef7ee 100644 --- a/docs/sphinx/index.rst +++ b/docs/sphinx/index.rst @@ -11,6 +11,7 @@ including the LoLa (Low Latency) implementation and Message Passing library. introduction README message_passing + code_analysis how_to_document .. toctree:: diff --git a/quality/static_analysis/static_analysis.bazelrc b/quality/static_analysis/static_analysis.bazelrc index 6257dab35..03fa9bf72 100644 --- a/quality/static_analysis/static_analysis.bazelrc +++ b/quality/static_analysis/static_analysis.bazelrc @@ -13,7 +13,7 @@ # Clang-tidy configuration # Run clang-tidy on all C++ targets with: bazel test --config=clang-tidy //... -test:clang-tidy --aspects=//:tools/lint/linters.bzl%clang_tidy_aspect +test:clang-tidy --aspects=//tools:lint/linters.bzl%clang_tidy_aspect test:clang-tidy --output_groups=+rules_lint_report # Use LLVM toolchain for clang-tidy so it can find system headers test:clang-tidy --extra_toolchains=@llvm_toolchain//:cc-toolchain-x86_64-linux diff --git a/tools/BUILD b/tools/BUILD new file mode 100644 index 000000000..2d4283f3d --- /dev/null +++ b/tools/BUILD @@ -0,0 +1,19 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +py_binary( + name = "generate_code_analysis", + srcs = ["generate_code_analysis.py"], + main = "generate_code_analysis.py", + visibility = ["//visibility:public"], +) diff --git a/tools/__pycache__/generate_code_analysis.cpython-312.pyc b/tools/__pycache__/generate_code_analysis.cpython-312.pyc new file mode 100644 index 000000000..a7dddb581 Binary files /dev/null and b/tools/__pycache__/generate_code_analysis.cpython-312.pyc differ diff --git a/tools/generate_code_analysis.py b/tools/generate_code_analysis.py new file mode 100644 index 000000000..f7f5e8e36 --- /dev/null +++ b/tools/generate_code_analysis.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Generate code analysis reports (coverage, CodeQL, clang-tidy) under docs/sphinx/_static/codeanalysis.""" + +import argparse +import collections +import glob +import hashlib +import html +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT_DIR = Path( + os.environ.get("BUILD_WORKSPACE_DIRECTORY") + or Path(__file__).resolve().parent.parent +) +REPORT_ROOT = ROOT_DIR / "docs" / "sphinx" / "_static" / "codeanalysis" + +DEFAULT_TARGETS = ["//score/message_passing:client_connection_test"] + +# Maximum filename length for most filesystems +MAX_FILENAME_LEN = 200 + + +def run_bazel(*args): + """Run a bazel command from the repository root.""" + return subprocess.run( + ["bazel", *args], cwd=ROOT_DIR, capture_output=True, text=True + ) + + +def run_bazel_checked(*args): + """Run a bazel command, returning True on success.""" + result = subprocess.run(["bazel", *args], cwd=ROOT_DIR) + return result.returncode == 0 + + +def get_output_path(): + """Get the Bazel output path.""" + result = run_bazel("info", "output_path") + return result.stdout.strip() + + +def write_placeholder(dest_dir, title, message): + """Write a placeholder HTML page when a report cannot be generated.""" + os.makedirs(dest_dir, exist_ok=True) + (Path(dest_dir) / "index.html").write_text( + f""" + + + + + {title} + + + +
+

{title}

+

{message}

+

Run tools/generate_code_analysis.sh to generate this report.

+
+ + +""", + encoding="utf-8", + ) + + +def safe_filename(path, root_dir, output_path): + """Create a safe filename from a path, truncating with hash if too long.""" + name = path + for prefix in [str(root_dir) + "/", output_path + "/", "/"]: + if name.startswith(prefix): + name = name[len(prefix):] + name = name.replace("/", "__").replace(":", "_") + if len(name) > MAX_FILENAME_LEN: + h = hashlib.sha1(name.encode()).hexdigest()[:12] + name = name[: MAX_FILENAME_LEN - 13] + "_" + h + return name + + +# --- Target Resolution --- + + +def resolve_linux_test_target(label): + """Resolve a test target to its _linux variant if it exists.""" + if label.endswith("_linux") or label.endswith("_qnx"): + return label + if "::" not in label and ":" in label and label.startswith("//"): + linux_label = label + "_linux" + result = run_bazel("query", linux_label) + if result.returncode == 0: + return linux_label + return label + + +def resolve_coverage_targets(patterns): + """Expand and resolve target patterns for coverage.""" + resolved = [] + seen = set() + + for pattern in patterns: + if "..." in pattern or ":all" in pattern or ":*" in pattern: + result = run_bazel("query", f"tests({pattern})") + expanded = result.stdout.strip().split("\n") if result.returncode == 0 else [] + expanded = [t for t in expanded if t] + else: + expanded = [pattern] + + if not expanded: + expanded = [pattern] + + for label in expanded: + resolved_label = resolve_linux_test_target(label) + if resolved_label not in seen: + seen.add(resolved_label) + resolved.append(resolved_label) + + return resolved + + +# --- Coverage Report --- + + +def parse_lcov(coverage_dat): + """Parse LCOV data file.""" + files = [] + current = None + + with open(coverage_dat, "r", encoding="utf-8", errors="replace") as f: + for raw_line in f: + line = raw_line.strip() + if line.startswith("SF:"): + if current: + files.append(current) + current = {"path": line[3:], "covered": 0, "total": 0, "has_da": False} + elif current is None: + continue + elif line.startswith("DA:"): + current["has_da"] = True + current["total"] += 1 + try: + if int(line.split(",", 1)[1]) > 0: + current["covered"] += 1 + except (ValueError, IndexError): + pass + elif line.startswith("LF:") and not current["has_da"]: + try: + current["total"] = int(line[3:]) + except ValueError: + pass + elif line.startswith("LH:") and not current["has_da"]: + try: + current["covered"] = int(line[3:]) + except ValueError: + pass + elif line == "end_of_record": + files.append(current) + current = None + + if current: + files.append(current) + return [e for e in files if e["total"] > 0] + + +def render_coverage_html(files): + """Render coverage HTML from parsed LCOV data.""" + files.sort(key=lambda e: e["path"]) + total_lines = sum(e["total"] for e in files) + covered_lines = sum(e["covered"] for e in files) + coverage_pct = (covered_lines / total_lines * 100.0) if total_lines else 0.0 + + summary_note = ( + "The Bazel-generated LCOV file contains no line coverage counters for the selected targets. " + "The report page is working, but the underlying coverage data is empty." + if total_lines == 0 + else "This is a fallback LCOV summary because genhtml is not available in the current environment." + ) + + rows = [] + for entry in files[:500]: + pct = (entry["covered"] / entry["total"] * 100.0) if entry["total"] else 0.0 + rows.append( + f'{html.escape(entry["path"])}' + f"{entry['covered']}{entry['total']}{pct:.1f}%" + ) + + tbody = "".join(rows) or 'No coverage entries found.' + + return f""" + + + + + Coverage Report + + + +

Coverage Report

+

Covered lines: {covered_lines}/{total_lines} ({coverage_pct:.1f}%)

+

{summary_note}

+ + + {tbody} +
FileCoveredTotalCoverage
+ + +""" + + +def generate_coverage_report(targets): + """Generate the coverage HTML report.""" + out_dir = REPORT_ROOT / "coverage" + coverage_targets = resolve_coverage_targets(targets) + + print(f"[coverage] Running Bazel coverage for targets: {' '.join(coverage_targets)}") + if not run_bazel_checked("coverage", *coverage_targets): + write_placeholder(out_dir, "Coverage Report", f"Bazel coverage failed for targets: {' '.join(targets)}.") + return False + + output_path = get_output_path() + coverage_dat = f"{output_path}/_coverage/_coverage_report.dat" + + if not os.path.isfile(coverage_dat): + write_placeholder(out_dir, "Coverage Report", f"Combined coverage data was not found at {coverage_dat}.") + return True + + shutil.rmtree(out_dir, ignore_errors=True) + os.makedirs(out_dir, exist_ok=True) + + if not shutil.which("genhtml"): + files = parse_lcov(coverage_dat) + (out_dir / "index.html").write_text(render_coverage_html(files), encoding="utf-8") + return True + + result = subprocess.run( + ["genhtml", coverage_dat, "--output-directory", str(out_dir), + "--show-details", "--legend", "--function-coverage", + "--branch-coverage", "--ignore-errors", "category,inconsistent"], + cwd=ROOT_DIR, + ) + if result.returncode != 0: + write_placeholder(out_dir, "Coverage Report", f"genhtml failed while rendering {coverage_dat}.") + return False + + print(f"[coverage] Wrote {out_dir}/index.html") + return True + + +# --- CodeQL Report --- + + +def render_codeql_html(results): + """Render CodeQL SARIF results as HTML.""" + by_level = collections.Counter((r.get("level") or "unknown") for r in results) + + rows = [] + for r in results[:300]: + message = r.get("message", {}).get("text", "") + rule_id = r.get("ruleId", "") + level = r.get("level", "") + file_path, start_line = "", "" + locations = r.get("locations", []) + if locations: + phys = locations[0].get("physicalLocation", {}) + file_path = phys.get("artifactLocation", {}).get("uri", "") + start_line = str(phys.get("region", {}).get("startLine", "")) + rows.append( + f"{html.escape(level)}{html.escape(rule_id)}" + f"{html.escape(file_path)}{html.escape(start_line)}" + f"{html.escape(message)}" + ) + + summary = ", ".join(f"{k}: {v}" for k, v in sorted(by_level.items())) or "no findings" + tbody = "".join(rows) or 'No findings.' + + return f""" + + + + + CodeQL Report + + + +

CodeQL Report

+

Total findings: {len(results)}
By level: {html.escape(summary)}

+

SARIF source: codeql.sarif

+ + + {tbody} +
LevelRuleFileLineMessage
+ + +""" + + +def generate_codeql_report(targets): + """Generate the CodeQL HTML report.""" + out_dir = REPORT_ROOT / "codeql" + codeql_targets = " ".join(targets) + + print(f"[codeql] Running CodeQL lint target for: {codeql_targets}") + if not run_bazel_checked("run", "//quality/static_analysis:codeql_lint", "--", f"--target={codeql_targets}"): + write_placeholder(out_dir, "CodeQL Report", f"CodeQL analysis failed for targets: {codeql_targets}.") + return False + + output_path = get_output_path() + sarif_path = f"{output_path}/codeql.sarif" + + shutil.rmtree(out_dir, ignore_errors=True) + os.makedirs(out_dir, exist_ok=True) + + if not os.path.isfile(sarif_path): + write_placeholder(out_dir, "CodeQL Report", f"CodeQL SARIF output was not found at {sarif_path}.") + return True + + shutil.copy2(sarif_path, out_dir / "codeql.sarif") + + with open(sarif_path, "r", encoding="utf-8") as f: + sarif = json.load(f) + + results = [] + for run in sarif.get("runs", []): + results.extend(run.get("results", [])) + + (out_dir / "index.html").write_text(render_codeql_html(results), encoding="utf-8") + print(f"[codeql] Wrote {out_dir}/index.html") + return True + + +# --- Clang-Tidy Report --- + + +def render_clang_tidy_html(raw_dir): + """Render clang-tidy report files as HTML.""" + reports = sorted(glob.glob(os.path.join(raw_dir, "*"))) + + rows = [] + for report in reports: + name = os.path.basename(report) + try: + with open(report, "r", encoding="utf-8", errors="replace") as f: + excerpt = "".join(f.readlines()[:40]) + except OSError: + excerpt = "Failed to read report file." + rows.append( + f"{html.escape(name)}
{html.escape(excerpt)}
" + ) + + if not reports: + body = "

No clang-tidy report artifacts were found. Run the generator locally and inspect Bazel outputs.

" + else: + body = ( + f"

Collected files: {len(reports)}

" + "" + + "".join(rows) + "
FileExcerpt
" + ) + + return f""" + + + + + Clang-Tidy Report + + + +

Clang-Tidy Report

+ {body} + + +""" + + +def generate_clang_tidy_report(targets): + """Generate the clang-tidy HTML report.""" + out_dir = REPORT_ROOT / "clang_tidy" + + print(f"[clang-tidy] Running clang-tidy via Bazel for targets: {' '.join(targets)}") + if not run_bazel_checked("test", "--config=clang-tidy", *targets, "--output_groups=+rules_lint_report"): + write_placeholder(out_dir, "Clang-Tidy Report", f"clang-tidy failed for targets: {' '.join(targets)}.") + return False + + output_path = get_output_path() + + shutil.rmtree(out_dir, ignore_errors=True) + raw_dir = out_dir / "raw" + os.makedirs(raw_dir, exist_ok=True) + + # Collect clang-tidy report files + search_dirs = [ROOT_DIR / "bazel-bin", ROOT_DIR / "bazel-testlogs", Path(output_path)] + for search_dir in search_dirs: + if not search_dir.exists(): + continue + for report in search_dir.rglob("*"): + if not report.is_file(): + continue + name_lower = report.name.lower() + # Match clang-tidy or lint report files + name_match = ("clang" in name_lower and "tidy" in name_lower) or \ + ("lint" in name_lower and "report" in name_lower) or \ + ".clang-tidy" in name_lower + ext_match = name_lower.endswith((".txt", ".log", ".out", ".report", ".json")) + if name_match and ext_match: + dest_name = safe_filename(str(report), str(ROOT_DIR), output_path) + shutil.copy2(report, raw_dir / dest_name) + + (out_dir / "index.html").write_text(render_clang_tidy_html(str(raw_dir)), encoding="utf-8") + print(f"[clang-tidy] Wrote {out_dir}/index.html") + return True + + +# --- Main --- + + +def main(): + parser = argparse.ArgumentParser(description="Generate code analysis reports.") + parser.add_argument("--coverage", action="store_true", help="Generate coverage report") + parser.add_argument("--codeql", action="store_true", help="Generate CodeQL report") + parser.add_argument("--clang-tidy", action="store_true", help="Generate clang-tidy report") + parser.add_argument("--all-targets", action="store_true", help="Analyze full workspace (//...)") + parser.add_argument("--targets", type=str, default=None, help="Comma-separated Bazel target patterns") + args = parser.parse_args() + + # If nothing specified, run all + if not args.coverage and not args.codeql and not args.clang_tidy: + args.coverage = args.codeql = args.clang_tidy = True + + targets = DEFAULT_TARGETS + if args.targets: + targets = [t.strip() for t in args.targets.split(",")] + if args.all_targets: + targets = ["//..."] + + os.makedirs(REPORT_ROOT, exist_ok=True) + status = 0 + + if args.coverage: + if not generate_coverage_report(targets): + status = 1 + + if args.codeql: + if not generate_codeql_report(targets): + status = 1 + + if args.clang_tidy: + if not generate_clang_tidy_report(targets): + status = 1 + + print(f"Reports available under {REPORT_ROOT}") + sys.exit(status) + + +if __name__ == "__main__": + main()