| #!/usr/bin/env python3 |
| # Copyright 2026 WebAssembly Community Group participants |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """Multi-metric differential test coverage reporter for Binaryen. |
| |
| Intersects `git diff` hunks with Clang/LLVM source-based code coverage |
| (`llvm-cov export`) to report Line, Region, Branch, MC/DC, and Function coverage |
| specifically for the code modified in a change. With -a / --annotate, also |
| prints the diff annotated with coverage information. |
| |
| Prints instructions for configuring a coverage reporting build and collecting |
| coverage information if a coverage build cannot be found automatically. |
| """ |
| |
| import argparse |
| import glob |
| import json |
| import os |
| import re |
| import shlex |
| import shutil |
| import subprocess |
| import sys |
| from dataclasses import dataclass, field |
| |
| SCRIPT_PATH = os.path.relpath(os.path.abspath(__file__)) |
| |
| REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| |
| CPP_EXTENSIONS = {'.c', '.cc', '.cpp', '.cxx', '.c++', '.h', '.hpp', '.inc'} |
| |
| HUNK_RE = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@') |
| UNREACHABLE_RE = re.compile( |
| r'\b(WASM_UNREACHABLE|WASM_BUILTIN_UNREACHABLE|__builtin_unreachable)\b' |
| r'|LCOV_EXCL_LINE|GCOVR_EXCL_LINE|COV_EXCL_LINE', |
| ) |
| DEFAULT_LABEL_RE = re.compile(r'^\s*default\s*:\s*(//.*|/\*.*\*/)?$') |
| |
| # CounterMappingRegion::RegionKind values in llvm-cov JSON export. |
| REGION_KIND_CODE = 0 |
| |
| |
| @dataclass |
| class Segment: |
| line: int |
| col: int |
| count: int |
| has_count: bool |
| is_region_entry: bool |
| is_gap_region: bool |
| |
| |
| @dataclass |
| class LineStat: |
| line: int |
| mapped: bool |
| count: int |
| |
| |
| @dataclass |
| class CodeRegion: |
| line_start: int |
| col_start: int |
| line_end: int |
| col_end: int |
| count: int |
| |
| |
| @dataclass |
| class BranchRecord: |
| line_start: int |
| col_start: int |
| line_end: int |
| col_end: int |
| true_count: int |
| false_count: int |
| |
| |
| @dataclass |
| class MCDCRecord: |
| line_start: int |
| col_start: int |
| line_end: int |
| col_end: int |
| conditions: list[bool] |
| |
| |
| @dataclass |
| class FunctionSpan: |
| name: str |
| demangled_name: str |
| line_start: int |
| line_end: int |
| count: int |
| |
| |
| @dataclass |
| class MetricCounts: |
| covered: int = 0 |
| total: int = 0 |
| |
| def add(self, covered: int, total: int): |
| self.covered += covered |
| self.total += total |
| |
| @property |
| def percent(self) -> float | None: |
| if self.total == 0: |
| return None |
| return (self.covered * 100.0) / self.total |
| |
| def format_summary(self) -> str: |
| if self.total == 0: |
| return 'N/A (0 in diff)' |
| return f'{self.covered} / {self.total} ({self.percent:.1f}%)' |
| |
| |
| @dataclass |
| class FileDiffCoverage: |
| rel_path: str |
| abs_path: str |
| diff_lines: list[int] |
| hunks: list[tuple[int, int]] |
| lines: MetricCounts = field(default_factory=MetricCounts) |
| regions: MetricCounts = field(default_factory=MetricCounts) |
| branches: MetricCounts = field(default_factory=MetricCounts) |
| mcdc: MetricCounts = field(default_factory=MetricCounts) |
| functions: MetricCounts = field(default_factory=MetricCounts) |
| non_executable_diff_lines: int = 0 |
| excluded_unreachable_lines: int = 0 |
| line_stats: dict[int, LineStat] = field(default_factory=dict) |
| uncovered_functions: list[FunctionSpan] = field(default_factory=list) |
| uncovered_lines: list[int] = field(default_factory=list) |
| uncovered_subregions: list[CodeRegion] = field(default_factory=list) |
| uncovered_branches: list[BranchRecord] = field(default_factory=list) |
| uncovered_mcdc: list[MCDCRecord] = field(default_factory=list) |
| in_coverage_map: bool = True |
| |
| |
| def find_llvm_tool(tool_name: str, explicit_path: str | None = None) -> str | None: |
| if explicit_path: |
| return explicit_path if shutil.which(explicit_path) or os.path.isfile(explicit_path) else None |
| llvm_version = os.environ.get('LLVM_VERSION') |
| if llvm_version: |
| found = shutil.which(f'{tool_name}-{llvm_version}') |
| if found: |
| return found |
| found = shutil.which(tool_name) |
| if found: |
| return found |
| # Search PATH for versioned binaries (e.g. llvm-cov-22) and pick the newest. |
| pattern = re.compile(rf'^{re.escape(tool_name)}-(\d+)$') |
| versioned: list[tuple[int, str]] = [] |
| for path_dir in os.environ.get('PATH', '').split(os.pathsep): |
| if not path_dir or not os.path.isdir(path_dir): |
| continue |
| try: |
| for entry in os.listdir(path_dir): |
| m = pattern.match(entry) |
| if m: |
| full = os.path.join(path_dir, entry) |
| if os.path.isfile(full) and os.access(full, os.X_OK): |
| versioned.append((int(m.group(1)), full)) |
| except OSError: |
| continue |
| if versioned: |
| versioned.sort(key=lambda item: item[0], reverse=True) |
| return versioned[0][1] |
| return None |
| |
| |
| def parse_cmake_cache(cache_path: str) -> dict[str, str]: |
| entries: dict[str, str] = {} |
| if not os.path.isfile(cache_path): |
| return entries |
| with open(cache_path, errors='replace') as f: |
| for raw_line in f: |
| line = raw_line.strip() |
| if not line or line.startswith(('#', '//')): |
| continue |
| if ':' in line and '=' in line: |
| key_type, val = line.split('=', 1) |
| key = key_type.split(':', 1)[0] |
| entries[key] = val |
| return entries |
| |
| |
| def check_build_dir_coverage_config(build_dir: str) -> tuple[bool, str, dict[str, str]]: |
| """Return (is_valid, reason, cmake_cache_dict).""" |
| cache_path = os.path.join(build_dir, 'CMakeCache.txt') |
| if not os.path.isfile(cache_path): |
| return False, f"No CMakeCache.txt found in '{build_dir}'.", {} |
| |
| cache = parse_cmake_cache(cache_path) |
| cxx = cache.get('CMAKE_CXX_COMPILER', '') |
| build_type = cache.get('CMAKE_BUILD_TYPE', 'Release').upper() |
| cxx_flags = ' '.join([ |
| cache.get('CMAKE_CXX_FLAGS', ''), |
| cache.get(f'CMAKE_CXX_FLAGS_{build_type}', ''), |
| ]) |
| c_flags = ' '.join([ |
| cache.get('CMAKE_C_FLAGS', ''), |
| cache.get(f'CMAKE_C_FLAGS_{build_type}', ''), |
| ]) |
| |
| cxx_base = os.path.basename(cxx) |
| if 'clang' not in cxx_base: |
| return ( |
| False, |
| f"Build in '{build_dir}' uses compiler '{cxx or 'unknown'}' instead of Clang " |
| "(Clang is required for source-based coverage mapping).", |
| cache, |
| ) |
| |
| required_flags = ('-fprofile-instr-generate', '-fcoverage-mapping') |
| missing_cxx = [fl for fl in required_flags if fl not in cxx_flags] |
| missing_c = [fl for fl in required_flags if fl not in c_flags] |
| if missing_cxx or missing_c: |
| return ( |
| False, |
| f"Build in '{build_dir}' is missing coverage flags " |
| f"({', '.join(required_flags)}) in CMAKE_C_FLAGS / CMAKE_CXX_FLAGS.", |
| cache, |
| ) |
| |
| return True, 'OK', cache |
| |
| |
| def discover_build_dir(explicit_dir: str | None) -> tuple[str, bool, str, dict[str, str]]: |
| """Find a coverage-configured build directory or report why candidates failed.""" |
| if explicit_dir: |
| abs_dir = os.path.abspath(explicit_dir) |
| ok, reason, cache = check_build_dir_coverage_config(abs_dir) |
| return abs_dir, ok, reason, cache |
| |
| env_dir = os.environ.get('BINARYEN_BUILD_DIR') |
| if env_dir: |
| abs_dir = os.path.abspath(env_dir) |
| ok, reason, cache = check_build_dir_coverage_config(abs_dir) |
| return abs_dir, ok, reason, cache |
| |
| candidates = [ |
| os.path.join(REPO_ROOT, 'out', 'cov'), |
| os.path.join(REPO_ROOT, 'build', 'cov'), |
| os.path.join(REPO_ROOT, 'cov'), |
| REPO_ROOT, |
| os.path.join(REPO_ROOT, 'out', 'debug'), |
| os.path.join(REPO_ROOT, 'build', 'debug'), |
| os.path.join(REPO_ROOT, 'debug'), |
| os.path.join(REPO_ROOT, 'out'), |
| os.path.join(REPO_ROOT, 'build'), |
| ] |
| |
| first_existing = None |
| for cand in candidates: |
| ok, reason, cache = check_build_dir_coverage_config(cand) |
| if ok: |
| return cand, True, reason, cache |
| if first_existing is None and os.path.isfile(os.path.join(cand, 'CMakeCache.txt')): |
| first_existing = (cand, False, reason, cache) |
| |
| if first_existing: |
| return first_existing |
| |
| default_cov_dir = os.path.join(REPO_ROOT, 'out', 'cov') |
| return default_cov_dir, False, f"No coverage build directory found (checked '{default_cov_dir}' and repo root).", {} |
| |
| |
| def print_setup_instructions(reason: str, suggested_build_dir: str = 'out/cov'): |
| rel_build = os.path.relpath(os.path.abspath(suggested_build_dir), REPO_ROOT) |
| if rel_build == '.': |
| rel_build = 'out/cov' |
| print(f'Error: Build is not configured for coverage reporting.\n Reason: {reason}\n', file=sys.stderr) |
| print( |
| f"""If you already have a coverage build directory, specify it with: |
| -B <dir> / --build-dir <dir> (or set BINARYEN_BUILD_DIR=<dir>) |
| |
| To configure a new Clang coverage build: |
| |
| 1. Configure CMake with Clang source-based coverage flags (e.g. in '{rel_build}'): |
| cmake -S . -B {rel_build} -G Ninja \\ |
| -DCMAKE_BUILD_TYPE=Debug \\ |
| -DCMAKE_C_COMPILER=clang \\ |
| -DCMAKE_CXX_COMPILER=clang++ \\ |
| -DCMAKE_C_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fcoverage-mcdc" \\ |
| -DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping -fcoverage-mcdc" |
| |
| 2. Build the targets exercised by your tests (e.g. wasm-opt, binaryen-unittests): |
| ninja -C {rel_build} wasm-opt binaryen-unittests |
| |
| 3. Run this tool with your test(s) to collect profiles and report diff coverage in one step: |
| {SCRIPT_PATH} -B {rel_build} --lit test/lit/passes/<your-test>.wast |
| {SCRIPT_PATH} -B {rel_build} --gtest "*YourTest*" |
| {SCRIPT_PATH} -B {rel_build} --run "{rel_build}/bin/wasm-opt ..." |
| """, |
| file=sys.stderr, |
| ) |
| |
| |
| def print_missing_profile_instructions(build_dir: str): |
| rel_build = os.path.relpath(build_dir, REPO_ROOT) |
| bin_prefix = f'{rel_build}/bin' if rel_build != '.' else './bin' |
| b_flag = f'-B {rel_build} ' if rel_build not in {'.', 'out/cov'} else '' |
| print( |
| f"Error: No coverage profile data (.profraw or .profdata) found in '{rel_build}'.\n", |
| file=sys.stderr, |
| ) |
| print( |
| f"""Run tests through coverage-diff.py to automatically generate and merge profiles: |
| |
| # Run specific lit test(s): |
| {SCRIPT_PATH} {b_flag}--lit test/lit/passes/<your-test>.wast |
| |
| # Run GTest unit tests (all or filtered): |
| {SCRIPT_PATH} {b_flag}--gtest "*YourFilter*" |
| |
| # Run an arbitrary command: |
| {SCRIPT_PATH} {b_flag}--run "{bin_prefix}/wasm-opt ..." |
| |
| Or run tests manually with LLVM_PROFILE_FILE set, then re-run coverage-diff.py: |
| LLVM_PROFILE_FILE="$(pwd)/{rel_build}/coverage/profraw/%p_%m.profraw" {bin_prefix}/binaryen-lit test/lit/... |
| {SCRIPT_PATH} {b_flag.rstrip()} |
| """, |
| file=sys.stderr, |
| ) |
| |
| |
| def find_object_file_for_source(build_dir: str, rel_path: str) -> str | None: |
| """Locate the compiled .o file for a C/C++ source file in build_dir.""" |
| if rel_path.endswith(('.h', '.hpp', '.inc')): |
| return None |
| # 1. Standard libbinaryen object path: CMakeFiles/binaryen.dir/<rel_path>.o |
| cand = os.path.join(build_dir, 'CMakeFiles', 'binaryen.dir', f'{rel_path}.o') |
| if os.path.isfile(cand): |
| return cand |
| # 2. Subdirectory target object path: <dir>/CMakeFiles/*.dir/<basename>.o |
| rel_dir = os.path.dirname(rel_path) |
| base_name = os.path.basename(rel_path) |
| matches = glob.glob(os.path.join(build_dir, rel_dir, 'CMakeFiles', '*.dir', f'{base_name}.o')) |
| if not matches: |
| matches = glob.glob( |
| os.path.join(build_dir, 'src', 'tools', 'CMakeFiles', '*.dir', '**', f'{base_name}.o'), |
| recursive=True, |
| ) |
| return matches[0] if matches else None |
| |
| |
| def find_coverage_objects( |
| build_dir: str, |
| explicit_objects: list[str] | None, |
| diff_rel_paths: list[str], |
| used_gtest: bool, |
| ) -> list[str]: |
| if explicit_objects: |
| return [os.path.abspath(obj) for obj in explicit_objects] |
| |
| # Fast path: if all modified files in the diff are .c/.cc/.cpp compilation units |
| # with corresponding .o files in build_dir, pass those .o files directly to |
| # llvm-cov export (~0.15s) instead of parsing all 300MB+ of libbinaryen.so (~12s). |
| obj_files: list[str] = [] |
| all_have_obj = bool(diff_rel_paths) |
| for rel_path in diff_rel_paths: |
| obj_file = find_object_file_for_source(build_dir, rel_path) |
| if obj_file: |
| obj_files.append(obj_file) |
| else: |
| all_have_obj = False |
| break |
| if all_have_obj and obj_files: |
| return obj_files |
| |
| objects: list[str] = [] |
| lib_dir = os.path.join(build_dir, 'lib') |
| for lib_name in ('libbinaryen.so', 'libbinaryen.dylib'): |
| lib_path = os.path.join(lib_dir, lib_name) |
| if os.path.isfile(lib_path): |
| objects.append(lib_path) |
| |
| bin_dir = os.path.join(build_dir, 'bin') |
| needed_bins: set[str] = set() |
| |
| # If static build (no shared libbinaryen), we need at least wasm-opt / binaryen-unittests. |
| if not objects: |
| needed_bins.update(['wasm-opt', 'binaryen-unittests']) |
| |
| if used_gtest: |
| needed_bins.add('binaryen-unittests') |
| |
| for rel_path in diff_rel_paths: |
| parts = rel_path.replace('\\', '/').split('/') |
| if len(parts) >= 3 and parts[0] == 'src' and parts[1] == 'tools': |
| tool_entry = parts[2] |
| if os.path.splitext(tool_entry)[1]: |
| needed_bins.add(os.path.splitext(tool_entry)[0]) |
| else: |
| needed_bins.add(tool_entry) |
| elif len(parts) >= 2 and parts[0] == 'test' and parts[1] == 'gtest': |
| needed_bins.add('binaryen-unittests') |
| elif rel_path.endswith(('.h', '.hpp')): |
| # Header changes might only be instantiated inside unit tests or tools. |
| needed_bins.update(['wasm-opt', 'binaryen-unittests']) |
| |
| for bin_name in sorted(needed_bins): |
| bin_path = os.path.join(bin_dir, bin_name) |
| if os.path.isfile(bin_path) and os.access(bin_path, os.X_OK): |
| objects.append(bin_path) |
| |
| # Fallback: if still empty, look for any executable in bin/ |
| if not objects and os.path.isdir(bin_dir): |
| for entry in sorted(os.listdir(bin_dir)): |
| if entry == 'binaryen-lit': |
| continue |
| full = os.path.join(bin_dir, entry) |
| if os.path.isfile(full) and os.access(full, os.X_OK): |
| objects.append(full) |
| break |
| |
| return objects |
| |
| |
| def remove_file_quietly(path: str): |
| try: |
| os.remove(path) |
| except OSError: |
| pass |
| |
| |
| def clear_coverage_profiles(build_dir: str) -> int: |
| """Remove all .profraw and .profdata files in build_dir and return count removed.""" |
| cov_dir = os.path.join(build_dir, 'coverage') |
| patterns = [ |
| os.path.join(cov_dir, 'profraw', '*.profraw'), |
| os.path.join(cov_dir, '*.profraw'), |
| os.path.join(cov_dir, '*.profdata'), |
| os.path.join(build_dir, '*.profraw'), |
| os.path.join(build_dir, 'default.profdata'), |
| os.path.join(build_dir, 'out', 'test', '**', '*.profraw'), |
| os.path.join(REPO_ROOT, '*.profraw'), |
| ] |
| removed = 0 |
| seen: set[str] = set() |
| for pat in patterns: |
| for path in glob.glob(pat, recursive=True): |
| if path not in seen and os.path.isfile(path): |
| seen.add(path) |
| try: |
| os.remove(path) |
| removed += 1 |
| except OSError: |
| pass |
| return removed |
| |
| |
| def run_test_commands( |
| build_dir: str, |
| lit_tests: list[str] | None, |
| gtest_filter: str | None, |
| custom_cmds: list[str] | None, |
| append_profiles: bool, |
| ) -> tuple[str, list[str]]: |
| """Execute requested tests with LLVM_PROFILE_FILE set and return profraw dir & list.""" |
| profraw_dir = os.path.join(build_dir, 'coverage', 'profraw') |
| os.makedirs(profraw_dir, exist_ok=True) |
| |
| if not append_profiles: |
| clear_coverage_profiles(build_dir) |
| |
| env = os.environ.copy() |
| env['LLVM_PROFILE_FILE'] = os.path.join(profraw_dir, '%p_%m.profraw') |
| bin_dir = os.path.join(build_dir, 'bin') |
| env['PATH'] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" |
| |
| commands: list[list[str] | str] = [] |
| if lit_tests: |
| lit_script = os.path.join(bin_dir, 'binaryen-lit') |
| if not os.path.isfile(lit_script): |
| rel_build = os.path.relpath(build_dir, REPO_ROOT) |
| print( |
| f"Error: '{lit_script}' not found. Configure and build '{rel_build}' first.", |
| file=sys.stderr, |
| ) |
| sys.exit(2) |
| commands.append([sys.executable, lit_script, '-vv', *lit_tests]) |
| |
| if gtest_filter is not None: |
| gtest_bin = os.path.join(bin_dir, 'binaryen-unittests') |
| if not os.path.isfile(gtest_bin): |
| rel_build = os.path.relpath(build_dir, REPO_ROOT) |
| print( |
| f"Error: '{gtest_bin}' not found. Build it with: ninja -C {rel_build} binaryen-unittests", |
| file=sys.stderr, |
| ) |
| sys.exit(2) |
| cmd = [gtest_bin] |
| if gtest_filter and gtest_filter != '*': |
| cmd.append(f'--gtest_filter={gtest_filter}') |
| commands.append(cmd) |
| |
| if custom_cmds: |
| commands.extend(custom_cmds) |
| |
| for cmd in commands: |
| if isinstance(cmd, list): |
| display = shlex.join(cmd) |
| res = subprocess.run(cmd, cwd=REPO_ROOT, env=env) |
| else: |
| display = cmd |
| res = subprocess.run(cmd, cwd=REPO_ROOT, env=env, shell=True) |
| if res.returncode != 0: |
| print(f'Error: Test command failed (exit code {res.returncode}): {display}', file=sys.stderr) |
| sys.exit(res.returncode) |
| |
| profraw_files = sorted(glob.glob(os.path.join(profraw_dir, '*.profraw'))) |
| return profraw_dir, profraw_files |
| |
| |
| def resolve_profdata( |
| build_dir: str, |
| llvm_profdata: str, |
| explicit_profdata: str | None, |
| profraw_files: list[str] | None, |
| objects: list[str] | None = None, |
| ) -> str | None: |
| if explicit_profdata: |
| return os.path.abspath(explicit_profdata) if os.path.isfile(explicit_profdata) else None |
| |
| cov_dir = os.path.join(build_dir, 'coverage') |
| profdata_path = os.path.join(cov_dir, 'coverage.profdata') |
| newest_obj_mtime = max( |
| (os.path.getmtime(o) for o in (objects or []) if os.path.isfile(o)), |
| default=0.0, |
| ) |
| |
| if profraw_files is None: |
| # Search standard locations for existing .profraw files. |
| search_patterns = [ |
| os.path.join(cov_dir, 'profraw', '*.profraw'), |
| os.path.join(cov_dir, '*.profraw'), |
| os.path.join(build_dir, '*.profraw'), |
| os.path.join(build_dir, 'out', 'test', '**', '*.profraw'), |
| os.path.join(REPO_ROOT, '*.profraw'), |
| ] |
| found: list[str] = [] |
| for pat in search_patterns: |
| found.extend(glob.glob(pat, recursive=True)) |
| profraw_files = sorted(set(found)) |
| |
| # Automatically discard any .profraw files recorded before the target objects were rebuilt. |
| if profraw_files and newest_obj_mtime > 0: |
| fresh_profraw: list[str] = [] |
| for p in profraw_files: |
| if os.path.getmtime(p) < newest_obj_mtime: |
| remove_file_quietly(p) |
| else: |
| fresh_profraw.append(p) |
| if len(fresh_profraw) != len(profraw_files): |
| remove_file_quietly(profdata_path) |
| profraw_files = fresh_profraw |
| |
| if profraw_files: |
| os.makedirs(cov_dir, exist_ok=True) |
| need_merge = not os.path.isfile(profdata_path) |
| if not need_merge: |
| profdata_mtime = os.path.getmtime(profdata_path) |
| need_merge = any(os.path.getmtime(p) > profdata_mtime for p in profraw_files) |
| if need_merge: |
| cmd = [llvm_profdata, 'merge', '-sparse', *profraw_files, '-o', profdata_path] |
| res = subprocess.run(cmd, capture_output=True, text=True) |
| if res.returncode != 0: |
| print(f'Error merging profile data with {llvm_profdata}:\n{res.stderr}', file=sys.stderr) |
| sys.exit(2) |
| return profdata_path |
| |
| if os.path.isfile(profdata_path): |
| if newest_obj_mtime > 0 and os.path.getmtime(profdata_path) < newest_obj_mtime: |
| remove_file_quietly(profdata_path) |
| else: |
| return profdata_path |
| root_profdata = os.path.join(build_dir, 'default.profdata') |
| if os.path.isfile(root_profdata): |
| if newest_obj_mtime > 0 and os.path.getmtime(root_profdata) < newest_obj_mtime: |
| remove_file_quietly(root_profdata) |
| else: |
| return root_profdata |
| return None |
| |
| |
| def get_git_diff_lines( |
| base_ref: str | None, |
| staged_only: bool, |
| ) -> tuple[str, dict[str, tuple[list[int], list[tuple[int, int]]]]]: |
| """Return (diff_description, {rel_path: (sorted_lines, [(start, count), ...])}).""" |
| if staged_only: |
| diff_cmd = ['git', 'diff', '--cached', '-U0', '--no-color'] |
| desc = 'staged changes (git diff --cached)' |
| elif base_ref: |
| diff_cmd = ['git', 'diff', '-U0', '--no-color', base_ref] |
| desc = f'changes vs {base_ref}' |
| else: |
| # Check if there are uncommitted C/C++ changes against HEAD first. |
| status_res = subprocess.run( |
| ['git', 'diff', '--name-only', 'HEAD'], |
| cwd=REPO_ROOT, |
| capture_output=True, |
| text=True, |
| ) |
| has_uncommitted_cpp = any( |
| os.path.splitext(p.strip())[1] in CPP_EXTENSIONS |
| for p in status_res.stdout.splitlines() |
| if p.strip() |
| ) |
| if has_uncommitted_cpp: |
| diff_cmd = ['git', 'diff', '-U0', '--no-color', 'HEAD'] |
| desc = 'uncommitted changes vs HEAD' |
| else: |
| # Fall back to merge-base with upstream or origin/main. |
| upstream = '@{upstream}' |
| mb_res = subprocess.run( |
| ['git', 'merge-base', upstream, 'HEAD'], |
| cwd=REPO_ROOT, |
| capture_output=True, |
| text=True, |
| ) |
| if mb_res.returncode != 0: |
| upstream = 'origin/main' |
| mb_res = subprocess.run( |
| ['git', 'merge-base', upstream, 'HEAD'], |
| cwd=REPO_ROOT, |
| capture_output=True, |
| text=True, |
| ) |
| if mb_res.returncode == 0 and mb_res.stdout.strip(): |
| merge_base = mb_res.stdout.strip() |
| diff_cmd = ['git', 'diff', '-U0', '--no-color', merge_base] |
| desc = f'branch changes vs {upstream} ({merge_base[:10]})' |
| else: |
| diff_cmd = ['git', 'diff', '-U0', '--no-color', 'HEAD~1'] |
| desc = 'changes vs HEAD~1' |
| |
| res = subprocess.run(diff_cmd, cwd=REPO_ROOT, capture_output=True, text=True) |
| if res.returncode != 0: |
| print(f'Error running git diff:\n{res.stderr}', file=sys.stderr) |
| sys.exit(2) |
| |
| file_diffs: dict[str, tuple[set[int], list[tuple[int, int]]]] = {} |
| current_file: str | None = None |
| |
| for raw_line in res.stdout.splitlines(): |
| if raw_line.startswith('+++ b/'): |
| rel_path = raw_line[6:].strip() |
| ext = os.path.splitext(rel_path)[1] |
| abs_path = os.path.join(REPO_ROOT, rel_path) |
| if ext in CPP_EXTENSIONS and os.path.isfile(abs_path) and not rel_path.startswith('third_party/'): |
| current_file = rel_path |
| if current_file not in file_diffs: |
| file_diffs[current_file] = (set(), []) |
| else: |
| current_file = None |
| elif raw_line.startswith('+++ /dev/null'): |
| current_file = None |
| elif current_file and raw_line.startswith('@@ '): |
| m = HUNK_RE.match(raw_line) |
| if m: |
| start_line = int(m.group(1)) |
| count = int(m.group(2)) if m.group(2) is not None else 1 |
| if count > 0: |
| lines_set, hunks = file_diffs[current_file] |
| lines_set.update(range(start_line, start_line + count)) |
| hunks.append((start_line, count)) |
| |
| result = { |
| rel: (sorted(lines_set), hunks) |
| for rel, (lines_set, hunks) in sorted(file_diffs.items()) |
| if lines_set |
| } |
| return desc, result |
| |
| |
| def compute_line_stats(raw_segments: list[list]) -> dict[int, LineStat]: |
| """Replicate llvm::coverage::LineCoverageIterator & LineCoverageStats.""" |
| if not raw_segments: |
| return {} |
| |
| segments = [ |
| Segment( |
| line=int(s[0]), |
| col=int(s[1]), |
| count=int(s[2]), |
| has_count=bool(s[3]), |
| is_region_entry=bool(s[4]), |
| is_gap_region=bool(s[5]), |
| ) |
| for s in raw_segments |
| ] |
| |
| max_line = segments[-1].line |
| seg_idx = 0 |
| num_segs = len(segments) |
| wrapped_seg: Segment | None = None |
| line_segs: list[Segment] = [] |
| stats: dict[int, LineStat] = {} |
| |
| def is_start_of_region(seg: Segment) -> bool: |
| return not seg.is_gap_region and seg.has_count and seg.is_region_entry |
| |
| for line in range(1, max_line + 1): |
| if seg_idx >= num_segs: |
| break |
| if line_segs: |
| wrapped_seg = line_segs[-1] |
| line_segs = [] |
| while seg_idx < num_segs and segments[seg_idx].line == line: |
| line_segs.append(segments[seg_idx]) |
| seg_idx += 1 |
| |
| min_region_count = sum(1 for s in line_segs if is_start_of_region(s)) |
| start_of_skipped = bool(line_segs) and not line_segs[0].has_count and line_segs[0].is_region_entry |
| |
| mapped = not start_of_skipped and ( |
| (wrapped_seg is not None and wrapped_seg.has_count) or (min_region_count > 0) |
| ) |
| if not mapped: |
| mapped = any(s.is_region_entry and s.has_count for s in line_segs) |
| |
| if not mapped: |
| stats[line] = LineStat(line=line, mapped=False, count=0) |
| continue |
| |
| exec_count = wrapped_seg.count if wrapped_seg is not None else 0 |
| if min_region_count > 0: |
| for s in line_segs: |
| if is_start_of_region(s): |
| exec_count = max(exec_count, s.count) |
| |
| stats[line] = LineStat(line=line, mapped=True, count=exec_count) |
| |
| return stats |
| |
| |
| def demangle_names(names: list[str]) -> dict[str, str]: |
| if not names: |
| return {} |
| demangler = find_llvm_tool('llvm-cxxfilt') or shutil.which('c++filt') |
| if not demangler: |
| return {n: n for n in names} |
| # Strip leading source filename prefix if present (e.g. "File.cpp:_Z...") |
| stripped = [n.split(':', 1)[-1] if ':' in n and n.split(':', 1)[0].endswith(('.cpp', '.cc', '.c', '.h')) else n for n in names] |
| res = subprocess.run( |
| [demangler], |
| input='\n'.join(stripped), |
| capture_output=True, |
| text=True, |
| ) |
| if res.returncode != 0: |
| return {n: n for n in names} |
| out_lines = res.stdout.splitlines() |
| if len(out_lines) != len(names): |
| return {n: n for n in names} |
| return dict(zip(names, [line.strip() for line in out_lines], strict=True)) |
| |
| |
| def find_unreachable_lines(source_lines: list[str]) -> set[int]: |
| """Identify 1-indexed lines with WASM_UNREACHABLE or exclusion markers.""" |
| unreachable: set[int] = set() |
| for idx, text in enumerate(source_lines): |
| line_no = idx + 1 |
| if UNREACHABLE_RE.search(text): |
| unreachable.add(line_no) |
| # Also exclude an immediately preceding standalone `default:` label. |
| if idx > 0 and DEFAULT_LABEL_RE.match(source_lines[idx - 1]): |
| unreachable.add(line_no - 1) |
| return unreachable |
| |
| |
| def extract_snippet(source_lines: list[str], line_start: int, col_start: int, line_end: int, col_end: int) -> str: |
| if line_start < 1 or line_start > len(source_lines): |
| return '' |
| if line_start == line_end: |
| line_text = source_lines[line_start - 1] |
| c_start = max(0, col_start - 1) |
| c_end = max(c_start, col_end - 1) if col_end > col_start else len(line_text) |
| snippet = line_text[c_start:c_end].strip() |
| return snippet or line_text.strip() |
| first_part = source_lines[line_start - 1][max(0, col_start - 1):].strip() |
| return f'{first_part} ...' |
| |
| |
| def group_line_ranges(lines: list[int]) -> list[tuple[int, int]]: |
| if not lines: |
| return [] |
| sorted_lines = sorted(lines) |
| ranges: list[tuple[int, int]] = [] |
| start = prev = sorted_lines[0] |
| for ln in sorted_lines[1:]: |
| if ln == prev + 1: |
| prev = ln |
| else: |
| ranges.append((start, prev)) |
| start = prev = ln |
| ranges.append((start, prev)) |
| return ranges |
| |
| |
| def populate_regions_and_functions( |
| file_cov: FileDiffCoverage, |
| func_entries: list[dict], |
| demangled_map: dict[str, str], |
| active_diff_lines: list[int], |
| active_diff_set: set[int], |
| unreachable_lines: set[int], |
| include_system_macros: bool, |
| ): |
| region_counts: dict[tuple[int, int, int, int], int] = {} |
| func_spans: dict[tuple[str, int, int], FunctionSpan] = {} |
| |
| executable_diff_lines = [ |
| ln for ln in active_diff_lines if file_cov.line_stats.get(ln) and file_cov.line_stats[ln].mapped |
| ] |
| |
| for fn in func_entries: |
| fn_regions = fn.get('regions', []) |
| if not fn_regions: |
| continue |
| outer = fn_regions[0] |
| f_start, f_end = int(outer[0]), int(outer[2]) |
| if any(f_start <= ln <= f_end for ln in executable_diff_lines): |
| raw_name = fn['name'] |
| demangled = demangled_map.get(raw_name, raw_name) |
| key = (demangled, f_start, f_end) |
| if key not in func_spans: |
| func_spans[key] = FunctionSpan( |
| name=raw_name, |
| demangled_name=demangled, |
| line_start=f_start, |
| line_end=f_end, |
| count=int(fn.get('count', 0)), |
| ) |
| else: |
| func_spans[key].count += int(fn.get('count', 0)) |
| |
| for reg in fn_regions: |
| r_lstart, r_cstart, r_lend, r_cend = int(reg[0]), int(reg[1]), int(reg[2]), int(reg[3]) |
| r_count, r_file_id, r_kind = int(reg[4]), int(reg[5]), int(reg[7]) |
| if r_kind != REGION_KIND_CODE or r_file_id != 0: |
| continue |
| if not include_system_macros and r_lstart == r_lend and r_cstart == r_cend: |
| continue |
| if r_lstart in unreachable_lines: |
| continue |
| r_key = (r_lstart, r_cstart, r_lend, r_cend) |
| region_counts[r_key] = region_counts.get(r_key, 0) + r_count |
| |
| for span in sorted(func_spans.values(), key=lambda s: (s.line_start, s.demangled_name)): |
| file_cov.functions.add(1 if span.count > 0 else 0, 1) |
| if span.count == 0: |
| file_cov.uncovered_functions.append(span) |
| |
| selected_regions: dict[tuple[int, int, int, int], int] = {} |
| lines_with_starting_region: set[int] = set() |
| for r_key, r_count in region_counts.items(): |
| if r_key[0] in active_diff_set: |
| selected_regions[r_key] = r_count |
| lines_with_starting_region.add(r_key[0]) |
| |
| for ln in active_diff_lines: |
| st = file_cov.line_stats.get(ln) |
| if not (st and st.mapped) or ln in lines_with_starting_region: |
| continue |
| enclosing = [ |
| (r_key, r_count) |
| for r_key, r_count in region_counts.items() |
| if r_key[0] <= ln <= r_key[2] |
| ] |
| if enclosing: |
| innermost_key, innermost_count = min( |
| enclosing, |
| key=lambda item: (item[0][2] - item[0][0], item[0][3] - item[0][1]), |
| ) |
| selected_regions[innermost_key] = innermost_count |
| |
| uncovered_line_set = set(file_cov.uncovered_lines) |
| for r_key in sorted(selected_regions): |
| r_count = selected_regions[r_key] |
| file_cov.regions.add(1 if r_count > 0 else 0, 1) |
| r_lstart, r_cstart, r_lend, r_cend = r_key |
| if r_count == 0 and r_lstart in active_diff_set and r_lstart not in uncovered_line_set: |
| # Skip multi-line block regions (e.g. `{` at the end of `if (...) {`) |
| # whose interior lines are already reported in uncovered_lines. |
| if r_lstart < r_lend and any( |
| ln in uncovered_line_set for ln in range(r_lstart + 1, r_lend + 1) |
| ): |
| continue |
| file_cov.uncovered_subregions.append( |
| CodeRegion(r_lstart, r_cstart, r_lend, r_cend, r_count), |
| ) |
| |
| |
| def format_span_loc(line_start: int, col_start: int, line_end: int, col_end: int) -> str: |
| if line_start == line_end: |
| return f'Line {line_start}:{col_start}-{col_end}' |
| return f'Lines {line_start}:{col_start}-{line_end}:{col_end}' |
| |
| |
| def populate_branches_and_mcdc( |
| file_cov: FileDiffCoverage, |
| file_entry: dict, |
| active_diff_set: set[int], |
| unreachable_lines: set[int], |
| include_system_macros: bool, |
| ): |
| branch_map: dict[tuple[int, int, int, int], list[int]] = {} |
| for br in file_entry.get('branches', []): |
| b_lstart, b_cstart, b_lend, b_cend = int(br[0]), int(br[1]), int(br[2]), int(br[3]) |
| b_true, b_false, b_file_id = int(br[4]), int(br[5]), int(br[6]) |
| if b_file_id != 0: |
| continue |
| if not include_system_macros and b_lstart == b_lend and b_cstart == b_cend: |
| continue |
| if b_lstart in unreachable_lines: |
| continue |
| if not any(ln in active_diff_set for ln in range(b_lstart, b_lend + 1)): |
| continue |
| b_key = (b_lstart, b_cstart, b_lend, b_cend) |
| if b_key not in branch_map: |
| branch_map[b_key] = [b_true, b_false] |
| else: |
| branch_map[b_key][0] += b_true |
| branch_map[b_key][1] += b_false |
| |
| for b_key in sorted(branch_map): |
| b_true, b_false = branch_map[b_key] |
| cov_outcomes = (1 if b_true > 0 else 0) + (1 if b_false > 0 else 0) |
| file_cov.branches.add(cov_outcomes, 2) |
| if cov_outcomes < 2: |
| file_cov.uncovered_branches.append( |
| BranchRecord(b_key[0], b_key[1], b_key[2], b_key[3], b_true, b_false), |
| ) |
| |
| for mcdc in file_entry.get('mcdc_records', []): |
| m_lstart, m_cstart, m_lend, m_cend = int(mcdc[0]), int(mcdc[1]), int(mcdc[2]), int(mcdc[3]) |
| m_file_id = int(mcdc[6]) |
| conditions = [bool(c) for c in mcdc[9]] if len(mcdc) > 9 and isinstance(mcdc[9], list) else [] |
| if m_file_id != 0 or not conditions or m_lstart in unreachable_lines: |
| continue |
| if not any(ln in active_diff_set for ln in range(m_lstart, m_lend + 1)): |
| continue |
| cov_conds = sum(1 for c in conditions if c) |
| file_cov.mcdc.add(cov_conds, len(conditions)) |
| if cov_conds < len(conditions): |
| file_cov.uncovered_mcdc.append( |
| MCDCRecord(m_lstart, m_cstart, m_lend, m_cend, conditions), |
| ) |
| |
| |
| def analyze_coverage( |
| cov_json: dict, |
| file_diffs: dict[str, tuple[list[int], list[tuple[int, int]]]], |
| include_unreachable: bool, |
| include_system_macros: bool, |
| ) -> list[FileDiffCoverage]: |
| """Intersect llvm-cov export JSON with git diff line sets across all metrics.""" |
| files_by_realpath: dict[str, dict] = {} |
| functions_by_realpath: dict[str, list[dict]] = {} |
| |
| for datum in cov_json.get('data', []): |
| for file_entry in datum.get('files', []): |
| rp = os.path.realpath(file_entry['filename']) |
| files_by_realpath[rp] = file_entry |
| for func_entry in datum.get('functions', []): |
| filenames = func_entry.get('filenames', []) |
| if filenames: |
| rp = os.path.realpath(filenames[0]) |
| functions_by_realpath.setdefault(rp, []).append(func_entry) |
| |
| all_func_names = [fn['name'] for funcs in functions_by_realpath.values() for fn in funcs] |
| demangled_map = demangle_names(list(set(all_func_names))) |
| |
| results: list[FileDiffCoverage] = [] |
| |
| for rel_path, (diff_lines, hunks) in file_diffs.items(): |
| abs_path = os.path.realpath(os.path.join(REPO_ROOT, rel_path)) |
| try: |
| with open(abs_path, errors='replace') as f: |
| source_lines = [line.rstrip('\r\n') for line in f] |
| except OSError: |
| source_lines = [] |
| |
| file_cov = FileDiffCoverage( |
| rel_path=rel_path, |
| abs_path=abs_path, |
| diff_lines=diff_lines, |
| hunks=hunks, |
| ) |
| |
| file_entry = files_by_realpath.get(abs_path) |
| if not file_entry: |
| file_cov.in_coverage_map = False |
| results.append(file_cov) |
| continue |
| |
| unreachable_lines = set() if include_unreachable else find_unreachable_lines(source_lines) |
| diff_line_set = set(diff_lines) |
| active_diff_lines = sorted(diff_line_set - unreachable_lines) |
| file_cov.excluded_unreachable_lines = len(diff_line_set & unreachable_lines) |
| active_diff_set = set(active_diff_lines) |
| |
| line_stats = compute_line_stats(file_entry.get('segments', [])) |
| file_cov.line_stats = line_stats |
| for ln in active_diff_lines: |
| st = line_stats.get(ln) |
| if st and st.mapped: |
| file_cov.lines.add(1 if st.count > 0 else 0, 1) |
| if st.count == 0: |
| file_cov.uncovered_lines.append(ln) |
| else: |
| file_cov.non_executable_diff_lines += 1 |
| |
| populate_regions_and_functions( |
| file_cov=file_cov, |
| func_entries=functions_by_realpath.get(abs_path, []), |
| demangled_map=demangled_map, |
| active_diff_lines=active_diff_lines, |
| active_diff_set=active_diff_set, |
| unreachable_lines=unreachable_lines, |
| include_system_macros=include_system_macros, |
| ) |
| populate_branches_and_mcdc( |
| file_cov=file_cov, |
| file_entry=file_entry, |
| active_diff_set=active_diff_set, |
| unreachable_lines=unreachable_lines, |
| include_system_macros=include_system_macros, |
| ) |
| results.append(file_cov) |
| |
| return results |
| |
| |
| def render_text_report( |
| diff_desc: str, |
| file_results: list[FileDiffCoverage], |
| annotate: bool, |
| context_lines: int, |
| ): |
| totals = { |
| 'lines': MetricCounts(), |
| 'regions': MetricCounts(), |
| 'branches': MetricCounts(), |
| 'mcdc': MetricCounts(), |
| 'functions': MetricCounts(), |
| } |
| total_excluded_unreachable = 0 |
| |
| for fc in file_results: |
| if not fc.in_coverage_map: |
| continue |
| totals['lines'].add(fc.lines.covered, fc.lines.total) |
| totals['regions'].add(fc.regions.covered, fc.regions.total) |
| totals['branches'].add(fc.branches.covered, fc.branches.total) |
| totals['mcdc'].add(fc.mcdc.covered, fc.mcdc.total) |
| totals['functions'].add(fc.functions.covered, fc.functions.total) |
| total_excluded_unreachable += fc.excluded_unreachable_lines |
| |
| print(f'=== Diff Coverage Report ({diff_desc}) ===\n') |
| print(f" {'Metric':<12} {'Covered / Total':<22} {'Details'}") |
| print(f" {'-' * 12} {'-' * 22} {'-' * 34}") |
| print(f" {'Lines':<12} {totals['lines'].format_summary():<22} Executable lines in diff") |
| print(f" {'Regions':<12} {totals['regions'].format_summary():<22} Sub-line / block AST regions in diff") |
| print(f" {'Branches':<12} {totals['branches'].format_summary():<22} True/False branch outcomes in diff") |
| if totals['mcdc'].total > 0: |
| print(f" {'MC/DC':<12} {totals['mcdc'].format_summary():<22} Condition independence pairs in diff") |
| print(f" {'Functions':<12} {totals['functions'].format_summary():<22} Modified functions called >= 1x") |
| |
| if total_excluded_unreachable > 0: |
| print(f'\n (Note: Excluded {total_excluded_unreachable} WASM_UNREACHABLE line(s); pass --include-unreachable to include)') |
| |
| if len(file_results) > 1: |
| print('\n--- Per-File Summary ---') |
| for fc in file_results: |
| if not fc.in_coverage_map: |
| print(f' {fc.rel_path}: (not in coverage mapping - header/uncompiled)') |
| continue |
| if fc.lines.total == 0: |
| print(f' {fc.rel_path}: (only comments / non-executable lines changed in diff)') |
| continue |
| parts = [ |
| f'Lines: {fc.lines.format_summary()}', |
| f'Regions: {fc.regions.format_summary()}', |
| f'Branches: {fc.branches.format_summary()}', |
| ] |
| if fc.mcdc.total > 0: |
| parts.append(f'MC/DC: {fc.mcdc.format_summary()}') |
| print(f" {fc.rel_path}\n {', '.join(parts)}") |
| |
| # Print actionable gaps for each file |
| any_gaps = False |
| for fc in file_results: |
| if not fc.in_coverage_map: |
| continue |
| has_file_gaps = bool( |
| fc.uncovered_functions |
| or fc.uncovered_lines |
| or fc.uncovered_subregions |
| or fc.uncovered_branches |
| or fc.uncovered_mcdc, |
| ) |
| if not has_file_gaps: |
| continue |
| |
| if not any_gaps: |
| print('\n=== Uncovered / Partially Covered Code in Diff ===') |
| any_gaps = True |
| |
| try: |
| with open(fc.abs_path, errors='replace') as f: |
| source_lines = [line.rstrip('\r\n') for line in f] |
| except OSError: |
| source_lines = [] |
| |
| print(f'\nFile: {fc.rel_path}') |
| |
| if fc.uncovered_functions: |
| print(' Uncovered Functions (0 calls):') |
| for fn in fc.uncovered_functions: |
| print(f' - Line {fn.line_start}-{fn.line_end}: {fn.demangled_name}') |
| |
| if fc.uncovered_lines: |
| print(' Uncovered Executable Lines (0x):') |
| for start, end in group_line_ranges(fc.uncovered_lines): |
| loc = f'Line {start}' if start == end else f'Lines {start}-{end}' |
| print(f' - {loc}:') |
| for ln in range(start, end + 1): |
| src = source_lines[ln - 1] if 1 <= ln <= len(source_lines) else '' |
| print(f' {ln:5d} | {src}') |
| |
| if fc.uncovered_subregions: |
| print(' Partially Covered Lines (Uncovered Sub-line Regions on Executed Lines):') |
| for reg in fc.uncovered_subregions: |
| snippet = extract_snippet( |
| source_lines, reg.line_start, reg.col_start, reg.line_end, reg.col_end, |
| ) |
| loc = format_span_loc(reg.line_start, reg.col_start, reg.line_end, reg.col_end) |
| full_line = source_lines[reg.line_start - 1].strip() if 1 <= reg.line_start <= len(source_lines) else '' |
| context_suffix = f' (in `{full_line}`)' if reg.line_start == reg.line_end and full_line != snippet else '' |
| print( |
| f' - {loc} (0x): `{snippet}`{context_suffix}', |
| ) |
| |
| if fc.uncovered_branches: |
| print(' Uncovered / One-Sided Branches:') |
| for br in fc.uncovered_branches: |
| snippet = extract_snippet( |
| source_lines, br.line_start, br.col_start, br.line_end, br.col_end, |
| ) |
| loc = format_span_loc(br.line_start, br.col_start, br.line_end, br.col_end) |
| if br.true_count == 0 and br.false_count == 0: |
| status = 'never reached (True: 0x, False: 0x)' |
| elif br.true_count == 0: |
| status = f'missing TRUE branch (True: 0x, False: {br.false_count}x)' |
| else: |
| status = f'missing FALSE branch (True: {br.true_count}x, False: 0x)' |
| print( |
| f' - {loc} `{snippet}` -> {status}', |
| ) |
| |
| if fc.uncovered_mcdc: |
| print(' Uncovered MC/DC Condition Independence Pairs:') |
| for rec in fc.uncovered_mcdc: |
| snippet = extract_snippet( |
| source_lines, rec.line_start, rec.col_start, rec.line_end, rec.col_end, |
| ) |
| loc = format_span_loc(rec.line_start, rec.col_start, rec.line_end, rec.col_end) |
| missing_conds = [f'C{i + 1}' for i, cov in enumerate(rec.conditions) if not cov] |
| print( |
| f" - {loc} `{snippet}` -> " |
| f"missing independence pair for {', '.join(missing_conds)}", |
| ) |
| |
| if not any_gaps and totals['lines'].total > 0: |
| print('\nAll executable lines, regions, and branches in the diff are 100% covered!') |
| |
| if annotate: |
| print('\n=== Annotated Diff Hunks ===') |
| for fc in file_results: |
| if not fc.in_coverage_map: |
| continue |
| try: |
| with open(fc.abs_path, errors='replace') as f: |
| source_lines = [line.rstrip('\r\n') for line in f] |
| except OSError: |
| continue |
| print(f'\n--- {fc.rel_path} ---') |
| diff_set = set(fc.diff_lines) |
| branches_by_line: dict[int, list[BranchRecord]] = {} |
| for br in fc.uncovered_branches: |
| branches_by_line.setdefault(br.line_start, []).append(br) |
| |
| for start, count in fc.hunks: |
| lo = max(1, start - context_lines) |
| hi = min(len(source_lines), start + count - 1 + context_lines) |
| print(f'@@ lines {lo}-{hi} @@') |
| for ln in range(lo, hi + 1): |
| marker = '+' if ln in diff_set else ' ' |
| st = fc.line_stats.get(ln) |
| if st and st.mapped: |
| cnt_str = f'{st.count:6d}x' if st.count > 0 else ' #####' |
| else: |
| cnt_str = ' ' |
| print(f'{marker} {ln:5d} | {cnt_str} | {source_lines[ln - 1]}') |
| for br in branches_by_line.get(ln, []): |
| print( |
| f' | | ^-- Branch ({br.line_start}:{br.col_start}): ' |
| f'[True: {br.true_count}x, False: {br.false_count}x]', |
| ) |
| |
| |
| def render_json_report(diff_desc: str, file_results: list[FileDiffCoverage]) -> str: |
| totals = { |
| 'lines': MetricCounts(), |
| 'regions': MetricCounts(), |
| 'branches': MetricCounts(), |
| 'mcdc': MetricCounts(), |
| 'functions': MetricCounts(), |
| } |
| for fc in file_results: |
| if not fc.in_coverage_map: |
| continue |
| totals['lines'].add(fc.lines.covered, fc.lines.total) |
| totals['regions'].add(fc.regions.covered, fc.regions.total) |
| totals['branches'].add(fc.branches.covered, fc.branches.total) |
| totals['mcdc'].add(fc.mcdc.covered, fc.mcdc.total) |
| totals['functions'].add(fc.functions.covered, fc.functions.total) |
| |
| payload = { |
| 'diff_target': diff_desc, |
| 'totals': { |
| k: {'covered': v.covered, 'total': v.total, 'percent': v.percent} |
| for k, v in totals.items() |
| }, |
| 'files': [ |
| { |
| 'file': fc.rel_path, |
| 'in_coverage_map': fc.in_coverage_map, |
| 'metrics': { |
| 'lines': {'covered': fc.lines.covered, 'total': fc.lines.total, 'percent': fc.lines.percent}, |
| 'regions': {'covered': fc.regions.covered, 'total': fc.regions.total, 'percent': fc.regions.percent}, |
| 'branches': {'covered': fc.branches.covered, 'total': fc.branches.total, 'percent': fc.branches.percent}, |
| 'mcdc': {'covered': fc.mcdc.covered, 'total': fc.mcdc.total, 'percent': fc.mcdc.percent}, |
| 'functions': {'covered': fc.functions.covered, 'total': fc.functions.total, 'percent': fc.functions.percent}, |
| }, |
| 'uncovered_functions': [ |
| {'name': fn.demangled_name, 'line_start': fn.line_start, 'line_end': fn.line_end} |
| for fn in fc.uncovered_functions |
| ], |
| 'uncovered_lines': fc.uncovered_lines, |
| 'uncovered_subregions': [ |
| { |
| 'line_start': r.line_start, |
| 'col_start': r.col_start, |
| 'line_end': r.line_end, |
| 'col_end': r.col_end, |
| } |
| for r in fc.uncovered_subregions |
| ], |
| 'uncovered_branches': [ |
| { |
| 'line_start': b.line_start, |
| 'col_start': b.col_start, |
| 'line_end': b.line_end, |
| 'col_end': b.col_end, |
| 'true_count': b.true_count, |
| 'false_count': b.false_count, |
| } |
| for b in fc.uncovered_branches |
| ], |
| } |
| for fc in file_results |
| ], |
| } |
| return json.dumps(payload, indent=2) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| ) |
| parser.add_argument( |
| 'base', |
| nargs='?', |
| default=None, |
| help='Git ref to diff against (default: HEAD if uncommitted changes exist, else merge-base with upstream)', |
| ) |
| parser.add_argument( |
| '--base', |
| dest='base_flag', |
| default=None, |
| help='Git ref to diff against (overrides positional base)', |
| ) |
| parser.add_argument( |
| '--staged', |
| action='store_true', |
| help='Analyze only staged changes (git diff --cached)', |
| ) |
| parser.add_argument( |
| '-B', |
| '--build-dir', |
| default=None, |
| help='CMake build directory configured with coverage flags (default: auto-detect out/cov, build/cov, .)', |
| ) |
| parser.add_argument( |
| '--lit', |
| nargs='+', |
| metavar='TEST', |
| help='Run binaryen-lit on the specified test file(s)/directories to collect coverage', |
| ) |
| parser.add_argument( |
| '--gtest', |
| nargs='?', |
| const='*', |
| default=None, |
| metavar='FILTER', |
| help='Run binaryen-unittests (optionally with --gtest_filter=FILTER) to collect coverage', |
| ) |
| parser.add_argument( |
| '--run', |
| action='append', |
| metavar='CMD', |
| help='Run custom command(s) with LLVM_PROFILE_FILE set to collect coverage', |
| ) |
| parser.add_argument( |
| '--append', |
| action='store_true', |
| help='Keep existing .profraw files when running tests via --lit/--gtest/--run', |
| ) |
| parser.add_argument( |
| '--clear', |
| action='store_true', |
| help='Clear all collected .profraw and .profdata files in the build directory', |
| ) |
| parser.add_argument( |
| '--profdata', |
| default=None, |
| help='Explicit path to a merged .profdata file', |
| ) |
| parser.add_argument( |
| '--object', |
| action='append', |
| dest='objects', |
| metavar='BIN', |
| help='Explicit instrumented library/executable path(s) for llvm-cov (auto-detected by default)', |
| ) |
| parser.add_argument( |
| '-a', |
| '--annotate', |
| action='store_true', |
| help='Print annotated diff hunks with per-line execution counts and branch counts', |
| ) |
| parser.add_argument( |
| '-C', |
| '--context', |
| type=int, |
| default=2, |
| help='Number of context lines around annotated diff hunks (default: 2)', |
| ) |
| parser.add_argument( |
| '--json', |
| action='store_true', |
| help='Emit coverage report as JSON', |
| ) |
| parser.add_argument( |
| '--include-unreachable', |
| action='store_true', |
| help='Include WASM_UNREACHABLE lines in coverage totals', |
| ) |
| parser.add_argument( |
| '--include-system-macros', |
| action='store_true', |
| help='Include 0-width system macro expansions (such as assert() internal branches)', |
| ) |
| parser.add_argument( |
| '--fail-under-lines', |
| type=float, |
| default=None, |
| metavar='PCT', |
| help='Exit with code 1 if diff line coverage percentage is below PCT', |
| ) |
| parser.add_argument( |
| '--fail-under-regions', |
| type=float, |
| default=None, |
| metavar='PCT', |
| help='Exit with code 1 if diff region coverage percentage is below PCT', |
| ) |
| parser.add_argument( |
| '--fail-under-branches', |
| type=float, |
| default=None, |
| metavar='PCT', |
| help='Exit with code 1 if diff branch coverage percentage is below PCT', |
| ) |
| parser.add_argument( |
| '--llvm-cov', |
| default=None, |
| help='Path to llvm-cov binary', |
| ) |
| parser.add_argument( |
| '--llvm-profdata', |
| default=None, |
| help='Path to llvm-profdata binary', |
| ) |
| |
| args = parser.parse_args() |
| base_ref = args.base_flag or args.base |
| |
| # 1. Verify llvm-cov and llvm-profdata are installed. |
| llvm_cov = find_llvm_tool('llvm-cov', args.llvm_cov) |
| llvm_profdata = find_llvm_tool('llvm-profdata', args.llvm_profdata) |
| if not llvm_cov or not llvm_profdata: |
| missing = [name for name, path in [('llvm-cov', llvm_cov), ('llvm-profdata', llvm_profdata)] if not path] |
| print( |
| f"Error: Required LLVM coverage tool(s) not found in PATH: {', '.join(missing)}\n" |
| 'Install the llvm package (e.g. `sudo apt-get install llvm`) or pass --llvm-cov / --llvm-profdata.', |
| file=sys.stderr, |
| ) |
| sys.exit(2) |
| |
| # 2. Discover & validate the CMake build directory. |
| build_dir, is_valid_config, reason, _cache = discover_build_dir(args.build_dir) |
| if not is_valid_config: |
| print_setup_instructions(reason, args.build_dir or 'out/cov') |
| sys.exit(2) |
| |
| if args.clear: |
| removed = clear_coverage_profiles(build_dir) |
| rel_build = os.path.relpath(build_dir, REPO_ROOT) |
| print(f"Cleared {removed} coverage profile file(s) from '{rel_build}'.") |
| if not (args.lit or args.gtest is not None or args.run): |
| sys.exit(0) |
| |
| # 3. Collect git diff hunks first so we know which files/binaries are involved. |
| diff_desc, file_diffs = get_git_diff_lines(base_ref, args.staged) |
| if not file_diffs: |
| print( |
| f'No modified C/C++ source files found in {diff_desc}.\n' |
| f'(Tip: pass a base ref such as `{SCRIPT_PATH} origin/main` or `HEAD~1` to compare committed changes.)', |
| ) |
| sys.exit(0) |
| |
| # 4. Run tests if --lit, --gtest, or --run was specified. |
| profraw_files: list[str] | None = None |
| if args.lit or args.gtest is not None or args.run: |
| _profraw_dir, profraw_files = run_test_commands( |
| build_dir=build_dir, |
| lit_tests=args.lit, |
| gtest_filter=args.gtest, |
| custom_cmds=args.run, |
| append_profiles=args.append, |
| ) |
| |
| # 5. Locate instrumented binary/library objects. |
| objects = find_coverage_objects( |
| build_dir=build_dir, |
| explicit_objects=args.objects, |
| diff_rel_paths=list(file_diffs.keys()), |
| used_gtest=args.gtest is not None, |
| ) |
| if not objects: |
| rel_build = os.path.relpath(build_dir, REPO_ROOT) |
| print( |
| f"Error: No built libraries or executables found in '{rel_build}'.\n" |
| f'Build your targets first, e.g.:\n ninja -C {rel_build} wasm-opt binaryen-unittests', |
| file=sys.stderr, |
| ) |
| sys.exit(2) |
| |
| # 6. Locate or merge .profdata (invalidating any profiles older than rebuilt objects). |
| profdata_path = resolve_profdata( |
| build_dir=build_dir, |
| llvm_profdata=llvm_profdata, |
| explicit_profdata=args.profdata, |
| profraw_files=profraw_files, |
| objects=objects, |
| ) |
| if not profdata_path: |
| print_missing_profile_instructions(build_dir) |
| sys.exit(2) |
| |
| # 7. Run `llvm-cov export` restricted to the modified files in the diff. |
| source_paths = [os.path.join(REPO_ROOT, rel) for rel in file_diffs] |
| cov_cmd = [ |
| llvm_cov, |
| 'export', |
| '--format=text', |
| '--skip-expansions', |
| f'--instr-profile={profdata_path}', |
| objects[0], |
| *[f'--object={obj}' for obj in objects[1:]], |
| '--', |
| *source_paths, |
| ] |
| cov_res = subprocess.run(cov_cmd, cwd=REPO_ROOT, capture_output=True, text=True) |
| if cov_res.returncode != 0: |
| rel_build = os.path.relpath(build_dir, REPO_ROOT) |
| print( |
| f'Error running llvm-cov export:\n{cov_res.stderr.strip()}\n', |
| file=sys.stderr, |
| ) |
| if 'No coverage data found' in cov_res.stderr or 'Failed to load coverage' in cov_res.stderr: |
| print_setup_instructions( |
| f"Binaries in '{rel_build}' do not contain LLVM coverage mapping sections.", |
| rel_build, |
| ) |
| sys.exit(2) |
| |
| cov_json = json.loads(cov_res.stdout) |
| file_results = analyze_coverage( |
| cov_json=cov_json, |
| file_diffs=file_diffs, |
| include_unreachable=args.include_unreachable, |
| include_system_macros=args.include_system_macros, |
| ) |
| |
| if args.json: |
| print(render_json_report(diff_desc, file_results)) |
| else: |
| render_text_report( |
| diff_desc=diff_desc, |
| file_results=file_results, |
| annotate=args.annotate, |
| context_lines=args.context, |
| ) |
| |
| # 8. Check optional threshold gates. |
| failed_gate = False |
| for metric_name, threshold in [ |
| ('lines', args.fail_under_lines), |
| ('regions', args.fail_under_regions), |
| ('branches', args.fail_under_branches), |
| ]: |
| if threshold is None: |
| continue |
| agg = MetricCounts() |
| for fc in file_results: |
| if fc.in_coverage_map: |
| m: MetricCounts = getattr(fc, metric_name) |
| agg.add(m.covered, m.total) |
| if agg.percent is not None and agg.percent < threshold: |
| print( |
| f'FAIL: Diff {metric_name} coverage ({agg.percent:.1f}%) is below threshold ({threshold:.1f}%).', |
| file=sys.stderr, |
| ) |
| failed_gate = True |
| |
| if failed_gate: |
| sys.exit(1) |
| |
| |
| if __name__ == '__main__': |
| main() |