| #!/usr/bin/env python3 | |
| # Copyright 2026 The Chromium Authors | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Programmatic inspector for LUCI Code Coverage builds. | |
| Responsible for: | |
| - Fetching raw build details and step history via Buildbucket CLI (bb). | |
| - Verifying gclient config and lookup GN args against | |
| language_coverage_map.json. | |
| - Verifying coverage pipeline steps (unit & overall), HTML report | |
| generation, and HTML upload across languages. | |
| - Extracting target file coverage metrics and uncovered lines. | |
| """ | |
| import argparse | |
| import gzip | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import zlib | |
| from enum import StrEnum | |
| from pathlib import Path | |
| from typing import Any | |
| class BuildStatus(StrEnum): | |
| """LUCI Buildbucket build and step status string constants.""" | |
| STATUS_UNSPECIFIED = 'STATUS_UNSPECIFIED' | |
| SCHEDULED = 'SCHEDULED' | |
| STARTED = 'STARTED' | |
| SUCCESS = 'SUCCESS' | |
| FAILURE = 'FAILURE' | |
| INFRA_FAILURE = 'INFRA_FAILURE' | |
| CANCELED = 'CANCELED' | |
| TERMINAL_BUILD_STATUSES = { | |
| BuildStatus.SUCCESS, | |
| BuildStatus.FAILURE, | |
| BuildStatus.INFRA_FAILURE, | |
| BuildStatus.CANCELED, | |
| } | |
| class StepStatus(StrEnum): | |
| """LUCI Buildbucket step status constants and diagnostic sentinels.""" | |
| STATUS_UNSPECIFIED = 'STATUS_UNSPECIFIED' | |
| SCHEDULED = 'SCHEDULED' | |
| STARTED = 'STARTED' | |
| SUCCESS = 'SUCCESS' | |
| FAILURE = 'FAILURE' | |
| INFRA_FAILURE = 'INFRA_FAILURE' | |
| CANCELED = 'CANCELED' | |
| NOT_FOUND = 'NOT_FOUND' | |
| DELEGATED_TO_COMPILATOR = 'DELEGATED_TO_COMPILATOR' | |
| INDENT = ' ' | |
| SRC_ROOT = Path(__file__).resolve().parents[2] | |
| def get_supported_languages() -> list[str]: | |
| """Returns sorted list of keys in language_coverage_map.json.""" | |
| map_path = Path(__file__).resolve().parent / 'language_coverage_map.json' | |
| with open(map_path, 'r', encoding='utf-8') as f: | |
| cov_map = json.load(f) | |
| return sorted(cov_map.keys()) | |
| def parse_build_id(build_link_or_id: str) -> str: | |
| """Extracts numeric build ID or converts URL to standard identifier. | |
| Args: | |
| build_link_or_id: Buildbucket build URL string or raw numeric ID. | |
| Returns: | |
| Numeric build ID string or identifier string. | |
| Raises: | |
| ValueError: If given string is not a numeric build ID or LUCI URL. | |
| """ | |
| s = str(build_link_or_id).strip() | |
| match_id = re.search(r'\b(\d{18,20})\b', s) | |
| if match_id: | |
| return match_id.group(1) | |
| match_url = re.search( | |
| r'ci\.chromium\.org/(?:ui/)?p/([^/]+)/builders/([^/]+)/([^/]+)/(\d+)', s | |
| ) | |
| if match_url: | |
| p, b, m, n = ( | |
| match_url.group(1), | |
| match_url.group(2), | |
| match_url.group(3), | |
| match_url.group(4), | |
| ) | |
| return f'{p}/{b}/{m}/{n}' | |
| raise ValueError( | |
| f'Invalid LUCI build ID or URL format: \'{build_link_or_id}\'. ' | |
| f'Expected an 18-20 digit numeric ID or a ci.chromium.org build URL.' | |
| ) | |
| def run_bb_get(build_id: str) -> dict[str, Any]: | |
| """Runs 'bb get -A -json <build_id>' and returns parsed dict. | |
| Args: | |
| build_id: Numeric build ID or builder path to inspect. | |
| Returns: | |
| Parsed JSON dictionary containing build properties and step history. | |
| """ | |
| cmd = ['bb', 'get', '-A', '-json', build_id] | |
| res = subprocess.run(cmd, capture_output=True, text=True, check=False) | |
| if res.returncode != 0: | |
| raise RuntimeError( | |
| f'Failed to fetch build {build_id} via bb get: {res.stderr}' | |
| ) | |
| return json.loads(res.stdout) | |
| def check_build_status(build_data: dict[str, Any]) -> dict[str, Any]: | |
| """Extracts terminal status and basic build identifiers. | |
| Args: | |
| build_data: Raw build dictionary returned by bb get. | |
| Returns: | |
| Dictionary reporting build ID, status, and terminal bool flag. | |
| """ | |
| build_id = str(build_data.get('id', '')) | |
| status = build_data.get('status', BuildStatus.STATUS_UNSPECIFIED) | |
| is_terminal = status in TERMINAL_BUILD_STATUSES | |
| return { | |
| 'build_id': build_id, | |
| 'status': status, | |
| 'terminal': is_terminal, | |
| } | |
| def verify_configuration( | |
| build_data: dict[str, Any], language: str | |
| ) -> dict[str, Any]: | |
| """Verifies gclient config & GN args against language_coverage_map.json. | |
| Args: | |
| build_data: Raw build dictionary returned by bb get. | |
| language: Programming language string identifying target requirements. | |
| Returns: | |
| Dictionary reporting validity, step status, and explicit mismatches. | |
| Raises: | |
| ValueError: If given language is not in language_coverage_map.json. | |
| """ | |
| map_path = Path(__file__).resolve().parent / 'language_coverage_map.json' | |
| with open(map_path, 'r', encoding='utf-8') as f: | |
| cov_map = json.load(f) | |
| if language not in cov_map: | |
| supported = ', '.join(sorted(cov_map.keys())) | |
| raise ValueError( | |
| f'Unsupported language \'{language}\'. Supported languages: {supported}' | |
| ) | |
| lang_config = cov_map[language] | |
| req_gclient = lang_config.get('required_gclient_vars', {}) | |
| in_props = build_data.get('input', {}).get('properties', {}) | |
| steps = build_data.get('steps', []) | |
| def _find_lookup_gn_status(step_list: list[dict[str, Any]]) -> str: | |
| for s in step_list: | |
| s_name = s.get('name', '') | |
| if ( | |
| s_name == 'lookup GN args' | |
| or s_name.endswith('|lookup GN args') | |
| or 'lookup GN args' in s_name | |
| ): | |
| return s.get('status', StepStatus.STATUS_UNSPECIFIED) | |
| for child_key in ('substeps', 'steps', 'children'): | |
| if child_key in s and isinstance(s[child_key], list): | |
| res = _find_lookup_gn_status(s[child_key]) | |
| if res != StepStatus.NOT_FOUND: | |
| return res | |
| return StepStatus.NOT_FOUND | |
| lookup_gn_step_status = _find_lookup_gn_status(steps) | |
| is_orchestrator = 'orchestrator' in str(in_props.get('recipe', '')) | |
| if is_orchestrator and lookup_gn_step_status == StepStatus.NOT_FOUND: | |
| lookup_gn_step_status = StepStatus.DELEGATED_TO_COMPILATOR | |
| gclient_valid = True | |
| gclient_mismatches = [] | |
| if req_gclient.get('checkout_clang_coverage_tools') and not is_orchestrator: | |
| has_clang_cov = 'use_clang_coverage' in str( | |
| in_props | |
| ) or 'checkout_clang_coverage_tools' in str(in_props) | |
| if not has_clang_cov: | |
| gclient_valid = False | |
| gclient_mismatches.append( | |
| 'Missing checkout_clang_coverage_tools in gclient config.' | |
| ) | |
| req_gn = lang_config.get('required_gn_args', {}) | |
| gn_arg_mismatches = [] | |
| gn_args = in_props.get('gn_args') | |
| if isinstance(gn_args, dict): | |
| for arg_k, expected_v in req_gn.items(): | |
| if arg_k in gn_args and gn_args[arg_k] != expected_v: | |
| gn_arg_mismatches.append( | |
| f'{arg_k} = {gn_args[arg_k]!r} (expected {expected_v!r})' | |
| ) | |
| elif not is_orchestrator: | |
| for arg_k, expected_v in req_gn.items(): | |
| if expected_v is True and in_props.get(arg_k) is False: | |
| gn_arg_mismatches.append(f'{arg_k} = False (expected True)') | |
| valid = gclient_valid and len(gn_arg_mismatches) == 0 | |
| return { | |
| 'configuration_valid': valid, | |
| 'lookup_gn_args_step_status': lookup_gn_step_status, | |
| 'gn_arg_mismatches': gn_arg_mismatches, | |
| 'gclient_config_mismatches': gclient_mismatches, | |
| } | |
| def first_matching_url( | |
| gsutil_urls: dict[str, str | None], candidate_keys: list[str] | |
| ) -> str | None: | |
| """Returns the first non-empty URL string found in gsutil_urls. | |
| Args: | |
| gsutil_urls: Dictionary mapping step names to GCS URLs. | |
| candidate_keys: Ordered list of property key strings to test. | |
| Returns: | |
| First matching URL string or None if no valid match is found. | |
| """ | |
| for key in candidate_keys: | |
| val = gsutil_urls.get(key) | |
| if val: | |
| return str(val) | |
| return None | |
| def verify_coverage_pipeline(build_data: dict[str, Any]) -> dict[str, Any]: | |
| """Verifies coverage pipeline execution and artifacts across languages. | |
| Args: | |
| build_data: Raw build dictionary returned by bb get. | |
| Returns: | |
| Dictionary reporting generation status, substeps, and artifact URLs across | |
| C++, Java, and JavaScript coverage pipelines. | |
| """ | |
| steps = build_data.get('steps', []) | |
| out_props = build_data.get('output', {}).get('properties', {}) | |
| gsutil_urls = out_props.get('gsutil_urls', {}) | |
| pipelines_config = [ | |
| ( | |
| 'cpp_overall', | |
| 'process clang code coverage data for overall test coverage', | |
| 'overall', | |
| ), | |
| ( | |
| 'cpp_unit', | |
| 'process clang code coverage data for unit test coverage', | |
| 'unit', | |
| ), | |
| ( | |
| 'java_overall', | |
| 'process java coverage (overall)', | |
| 'overall', | |
| ), | |
| ( | |
| 'java_unit', | |
| 'process java coverage (unit)', | |
| 'unit', | |
| ), | |
| ( | |
| 'js_overall', | |
| 'process javascript coverage (overall)', | |
| 'overall', | |
| ), | |
| ] | |
| results = {} | |
| all_pipelines_valid = True | |
| build_id_str = str(build_data.get('id', '')) | |
| for p_key, parent_step_name, p_level in pipelines_config: | |
| parent_status = StepStatus.NOT_FOUND | |
| artifacts_upload_status = StepStatus.NOT_FOUND | |
| tests_processed_count = 0 | |
| skipped_no_data = False | |
| child_steps = [] | |
| recipe_messages = [] | |
| prefix = f'{parent_step_name}|' | |
| for s in steps: | |
| s_name = s.get('name', '') | |
| st = s.get('status', StepStatus.STATUS_UNSPECIFIED) | |
| props = s.get('output', {}).get('properties', {}) | |
| no_prof_msg = ( | |
| 'skip processing clang coverage data because no profile data collected' | |
| ) | |
| if no_prof_msg in s_name: | |
| recipe_messages.append( | |
| { | |
| 'detected_condition': no_prof_msg, | |
| 'step_name': s_name, | |
| } | |
| ) | |
| if 'merge errors' in props or 'Found invalid profraw files' in str(s): | |
| recipe_messages.append( | |
| { | |
| 'detected_condition': 'Found invalid profraw files', | |
| 'step_name': s_name, | |
| } | |
| ) | |
| if props.get('process_coverage_data_failure'): | |
| recipe_messages.append( | |
| { | |
| 'detected_condition': 'process_coverage_data_failure = True', | |
| 'step_name': s_name, | |
| } | |
| ) | |
| if s_name == parent_step_name: | |
| parent_status = st | |
| continue | |
| if s_name.startswith(prefix): | |
| sub_name = s_name[len(prefix) :] | |
| child_steps.append({'name': sub_name, 'status': st}) | |
| if 'skip processing because no data is found' in sub_name: | |
| skipped_no_data = True | |
| recipe_messages.append( | |
| { | |
| 'detected_condition': 'skip processing because no data is found', | |
| 'step_name': sub_name, | |
| } | |
| ) | |
| elif 'skip processing because no profdata was generated' in sub_name: | |
| recipe_messages.append( | |
| { | |
| 'detected_condition': 'skip processing because no profdata was generated', | |
| 'step_name': sub_name, | |
| } | |
| ) | |
| if sub_name.startswith( | |
| ( | |
| 'generate html report', | |
| 'Generate Java coverage metadata', | |
| 'generate javascript html report', | |
| 'Generate JavaScript coverage metadata', | |
| ) | |
| ): | |
| match_count = re.search(r'in (\d+) tests', s_name) | |
| if match_count: | |
| tests_processed_count = int(match_count.group(1)) | |
| elif sub_name.startswith('gsutil Upload coverage artifacts'): | |
| artifacts_upload_status = st | |
| if ( | |
| parent_status == StepStatus.NOT_FOUND | |
| and not child_steps | |
| and not recipe_messages | |
| ): | |
| continue | |
| upload_step_name = ( | |
| 'gsutil Upload coverage artifacts (2)' | |
| if p_level == 'overall' | |
| else 'gsutil Upload coverage artifacts' | |
| ) | |
| if artifacts_upload_status == StepStatus.NOT_FOUND: | |
| for s in steps: | |
| if s.get('name', '') == upload_step_name: | |
| artifacts_upload_status = s.get( | |
| 'status', StepStatus.STATUS_UNSPECIFIED | |
| ) | |
| break | |
| if artifacts_upload_status == StepStatus.NOT_FOUND: | |
| fallback_upload_name = ( | |
| 'gsutil Upload coverage artifacts' | |
| if p_level == 'overall' | |
| else 'gsutil Upload coverage artifacts (2)' | |
| ) | |
| for s in steps: | |
| if s.get('name', '') == fallback_upload_name: | |
| artifacts_upload_status = s.get( | |
| 'status', StepStatus.STATUS_UNSPECIFIED | |
| ) | |
| break | |
| upload_step_alt = ( | |
| 'gsutil Upload coverage artifacts' | |
| if upload_step_name == 'gsutil Upload coverage artifacts (2)' | |
| else '' | |
| ) | |
| base_gs = first_matching_url( | |
| gsutil_urls, | |
| [ | |
| f'{parent_step_name}|{upload_step_name}', | |
| f'{parent_step_name}|gsutil Upload coverage artifacts', | |
| upload_step_name, | |
| upload_step_alt, | |
| ], | |
| ) | |
| profdata_gcs_url = gsutil_urls.get( | |
| f'{parent_step_name}|gsutil upload artifact to GS', '' | |
| ) | |
| all_json_gz_url = base_gs.rstrip('/') + '/all.json.gz' if base_gs else '' | |
| if ( | |
| not all_json_gz_url | |
| and build_id_str | |
| and parent_status == StepStatus.SUCCESS | |
| and not os.environ.get('INSPECT_COVERAGE_NO_NETWORK') | |
| ): | |
| gsutil_py = SRC_ROOT / 'third_party' / 'depot_tools' / 'gsutil.py' | |
| gcs_pattern = ( | |
| f'gs://code-coverage-data/**/{build_id_str}/{p_level}/**all.json.gz' | |
| ) | |
| ls_cmd = [sys.executable, str(gsutil_py), 'ls', gcs_pattern] | |
| res = subprocess.run(ls_cmd, capture_output=True, text=True, check=False) | |
| if res.returncode == 0 and res.stdout.strip(): | |
| all_json_gz_url = res.stdout.strip().splitlines()[0] | |
| pipeline_success = ( | |
| parent_status == StepStatus.SUCCESS | |
| and artifacts_upload_status == StepStatus.SUCCESS | |
| ) | |
| if ( | |
| parent_status != StepStatus.NOT_FOUND | |
| or len(child_steps) > 0 | |
| or len(recipe_messages) > 0 | |
| ): | |
| results[p_key] = { | |
| 'parent_step_status': parent_status, | |
| 'artifacts_upload_status': artifacts_upload_status, | |
| 'tests_processed_count': tests_processed_count, | |
| 'skipped_no_data': skipped_no_data, | |
| 'recipe_error_messages_detected': recipe_messages, | |
| 'child_steps': child_steps, | |
| 'merged_profdata_gcs_url': profdata_gcs_url, | |
| 'all_json_gz_url': all_json_gz_url, | |
| 'pipeline_success': pipeline_success, | |
| } | |
| if not pipeline_success: | |
| all_pipelines_valid = False | |
| return { | |
| 'pipelines_checked': results, | |
| 'all_pipelines_successful': all_pipelines_valid and len(results) > 0, | |
| } | |
| def format_line_ranges(line_nums: list[int]) -> str: | |
| """Formats a sorted list of line numbers into compact ranges (e.g. '1-3, 5'). | |
| Args: | |
| line_nums: List of integer line numbers. | |
| Returns: | |
| Comma-separated string of line numbers and ranges. | |
| """ | |
| if not line_nums: | |
| return 'None' | |
| sorted_nums = sorted(set(line_nums)) | |
| ranges = [] | |
| start = sorted_nums[0] | |
| end = start | |
| for n in sorted_nums[1:]: | |
| if n == end + 1: | |
| end = n | |
| else: | |
| ranges.append(f'{start}-{end}' if start != end else f'{start}') | |
| start = n | |
| end = n | |
| ranges.append(f'{start}-{end}' if start != end else f'{start}') | |
| return ', '.join(ranges) | |
| _ALL_JSON_GZ_CACHE: dict[str, dict[str, Any] | None] = {} | |
| def download_and_parse_all_json_gz_once( | |
| all_json_gz_url: str, force_reload: bool = False | |
| ) -> dict[str, Any] | None: | |
| """Downloads, decompresses, and caches a GCS all.json.gz metadata bundle. | |
| Args: | |
| all_json_gz_url: GCS URI to the target all.json.gz bundle. | |
| force_reload: If True, bypasses and clears the in-memory cache for url. | |
| Returns: | |
| Parsed JSON dictionary or None if unavailable. | |
| """ | |
| if not all_json_gz_url: | |
| return None | |
| if force_reload: | |
| _ALL_JSON_GZ_CACHE.pop(all_json_gz_url, None) | |
| if all_json_gz_url in _ALL_JSON_GZ_CACHE: | |
| return _ALL_JSON_GZ_CACHE[all_json_gz_url] | |
| gsutil_py = SRC_ROOT / 'third_party' / 'depot_tools' / 'gsutil.py' | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix='.json.gz') as tf: | |
| tmp_path = tf.name | |
| res = subprocess.run( | |
| [sys.executable, str(gsutil_py), 'cp', all_json_gz_url, tmp_path], | |
| capture_output=True, | |
| check=False, | |
| ) | |
| if res.returncode != 0 or not os.path.exists(tmp_path): | |
| _ALL_JSON_GZ_CACHE[all_json_gz_url] = None | |
| return None | |
| with open(tmp_path, 'rb') as f: | |
| raw = f.read() | |
| try: | |
| data = json.loads(zlib.decompress(raw)) | |
| except Exception: | |
| try: | |
| data = json.loads(gzip.decompress(raw)) | |
| except Exception: | |
| data = json.loads(raw) | |
| _ALL_JSON_GZ_CACHE[all_json_gz_url] = data | |
| return data | |
| except Exception: | |
| _ALL_JSON_GZ_CACHE[all_json_gz_url] = None | |
| return None | |
| def fetch_and_parse_json_file_coverage( | |
| all_json_gz_url: str, clean_path: str, force_reload: bool = False | |
| ) -> dict[str, Any]: | |
| """Extracts per-file coverage summary from a cached GCS metadata bundle. | |
| Args: | |
| all_json_gz_url: GCS URI to the target all.json.gz metadata bundle. | |
| clean_path: Target source file relative path. | |
| force_reload: If True, bypasses and clears the in-memory cache for url. | |
| Returns: | |
| Dictionary containing coverage percentages and uncovered lines. | |
| """ | |
| data = download_and_parse_all_json_gz_once(all_json_gz_url, force_reload) | |
| if not data: | |
| return {'available': False} | |
| try: | |
| files = data.get('files', []) if isinstance(data, dict) else [] | |
| target = None | |
| for item in files: | |
| if isinstance(item, dict): | |
| path = item.get('path', item.get('filename', '')) | |
| if not path: | |
| continue | |
| if path.endswith(clean_path) or clean_path.endswith(path): | |
| target = item | |
| break | |
| if not target: | |
| return {'available': False} | |
| uncovered_lines = [] | |
| for l_item in target.get('lines', []): | |
| if isinstance(l_item, dict) and l_item.get('count', 0) == 0: | |
| uncovered_lines.extend( | |
| range(l_item.get('first', 0), l_item.get('last', 0) + 1) | |
| ) | |
| summaries_data = target.get('summaries', target.get('summary', [])) | |
| if isinstance(summaries_data, dict): | |
| summaries = {} | |
| for k_name, k_dict in summaries_data.items(): | |
| if ( | |
| isinstance(k_dict, dict) | |
| and 'covered' in k_dict | |
| and ('count' in k_dict or 'total' in k_dict) | |
| ): | |
| tot = k_dict.get('count', k_dict.get('total', 0)) | |
| summaries[k_name] = { | |
| 'covered': k_dict.get('covered', 0), | |
| 'total': tot, | |
| } | |
| else: | |
| summaries = { | |
| s.get('name'): s for s in summaries_data if isinstance(s, dict) | |
| } | |
| def fmt_summary(name: str, alt_name: str = '') -> str: | |
| s = summaries.get(name) or (summaries.get(alt_name) if alt_name else None) | |
| if not s: | |
| return 'N/A' | |
| cov = s.get('covered', 0) | |
| tot = s.get('total', s.get('count', 0)) | |
| pct = (cov / tot * 100.0) if tot > 0 else 0.0 | |
| return f'{cov}/{tot} ({pct:.2f}%)' | |
| return { | |
| 'available': True, | |
| 'line_coverage': fmt_summary('line', 'lines'), | |
| 'function_coverage': fmt_summary('method', 'functions'), | |
| 'region_coverage': fmt_summary('instruction', 'regions'), | |
| 'branch_coverage': fmt_summary('branch', 'branches'), | |
| 'uncovered_lines': sorted(list(set(uncovered_lines))), | |
| } | |
| except Exception: | |
| return {'available': False} | |
| def extract_coverage_artifacts_for_files( | |
| pipeline_report: dict[str, Any], | |
| target_files: list[str] | None = None, | |
| ) -> dict[str, dict[str, Any]]: | |
| """Extracts per-file coverage metrics and uncovered lines across pipelines. | |
| Args: | |
| pipeline_report: Dictionary returned by verify_coverage_pipeline. | |
| target_files: Optional list of relative source file paths to inspect. | |
| Returns: | |
| Dictionary mapping each target file path to its pipeline coverage data. | |
| """ | |
| files = target_files or [] | |
| pipelines_checked = pipeline_report.get('pipelines_checked', {}) | |
| file_results = {} | |
| for f_path in files: | |
| clean_path = f_path.lstrip('/') | |
| file_info = {} | |
| for p_type, p_details in pipelines_checked.items(): | |
| json_gz_url = p_details.get('all_json_gz_url', '') | |
| if json_gz_url and p_details.get('pipeline_success'): | |
| parsed = fetch_and_parse_json_file_coverage(json_gz_url, clean_path) | |
| else: | |
| parsed = {'available': False} | |
| file_info[p_type] = parsed | |
| file_results[clean_path] = file_info | |
| return file_results | |
| def format_file_coverage_report( | |
| file_coverage_data: dict[str, dict[str, Any]], | |
| ) -> str: | |
| """Formats extracted target file coverage data into readable text summary. | |
| Args: | |
| file_coverage_data: Dictionary mapping file paths to pipeline info. | |
| Returns: | |
| Formatted multiline report string for target files. | |
| """ | |
| if not file_coverage_data: | |
| return '' | |
| lines = ['\n[Target File Coverage Extraction]'] | |
| for f_path, p_info in file_coverage_data.items(): | |
| lines.append(f'{INDENT}* File: {f_path}') | |
| for p_type, details in p_info.items(): | |
| if not details.get('available'): | |
| lines.append(f'{INDENT * 2}- Pipeline \'{p_type}\': Not Available') | |
| continue | |
| line_cov = details.get('line_coverage', 'N/A') | |
| func_cov = details.get('function_coverage', 'N/A') | |
| reg_cov = details.get('region_coverage', 'N/A') | |
| branch_cov = details.get('branch_coverage', 'N/A') | |
| lines.append(f'{INDENT * 2}- Pipeline {p_type!r}:') | |
| lines.append( | |
| f'{INDENT * 3}* Line Coverage: {line_cov} | Function: {func_cov}' | |
| f' | Region: {reg_cov} | Branch: {branch_cov}' | |
| ) | |
| uncovered = details.get('uncovered_lines', []) | |
| ranges_str = format_line_ranges(uncovered) | |
| lines.append( | |
| f'{INDENT * 3}* Uncovered Lines ({len(uncovered)}): {ranges_str}' | |
| ) | |
| return '\n'.join(lines) | |
| def format_inspection_report( | |
| combined_report: dict[str, Any], language: str | |
| ) -> str: | |
| """Formats combined build inspection report as readable text. | |
| Args: | |
| combined_report: Combined dictionary from all inspection phases. | |
| language: Programming language context string. | |
| Returns: | |
| Formatted multi-line string for human inspection. | |
| """ | |
| status_report = combined_report['status_check'] | |
| config_report = combined_report['config_verification'] | |
| pipeline_report = combined_report['coverage_pipeline_verification'] | |
| file_report = combined_report['target_file_coverage'] | |
| build_id = status_report['build_id'] | |
| status = status_report['status'] | |
| terminal = status_report['terminal'] | |
| config_valid = 'YES' if config_report['configuration_valid'] else 'NO' | |
| gn_step_status = config_report['lookup_gn_args_step_status'] | |
| pipelines_valid = ( | |
| 'YES' if pipeline_report['all_pipelines_successful'] else 'NO' | |
| ) | |
| lines = [] | |
| lines.append('=== Build Inspection (Status, Configuration & Pipeline) ===') | |
| lines.append(f'Build ID: {build_id}') | |
| lines.append(f'Terminal Status: {status} (Terminal: {terminal})') | |
| if not terminal: | |
| lines.append(f'Build status is non-terminal ({status}).') | |
| lines.append(f'\n[Configuration Verification ({language})]') | |
| lines.append(f'{INDENT}* Configuration Valid: {config_valid}') | |
| lines.append(f'{INDENT}* lookup GN args step: {gn_step_status}') | |
| if config_report['gn_arg_mismatches']: | |
| for m in config_report['gn_arg_mismatches']: | |
| lines.append(f'{INDENT * 2}- MISMATCH: {m}') | |
| if config_report['gclient_config_mismatches']: | |
| for m in config_report['gclient_config_mismatches']: | |
| lines.append(f'{INDENT * 2}- MISMATCH: {m}') | |
| lines.append('\n[Coverage Pipeline & HTML Report Verification]') | |
| lines.append(f'{INDENT}* All Pipelines Successful: {pipelines_valid}') | |
| for p_type, details in pipeline_report['pipelines_checked'].items(): | |
| parent_status = details['parent_step_status'] | |
| tests_cnt = details['tests_processed_count'] | |
| artifacts_up = details.get('artifacts_upload_status', 'N/A') | |
| lines.append(f'{INDENT}* Pipeline: {p_type!r}') | |
| lines.append( | |
| f'{INDENT * 2}- Parent Step: {parent_status} ({tests_cnt} tests) ' | |
| f'| Artifacts Upload: {artifacts_up}' | |
| ) | |
| if details.get('recipe_error_messages_detected'): | |
| for r_msg in details['recipe_error_messages_detected']: | |
| cond = r_msg['detected_condition'] | |
| step_nm = r_msg['step_name'] | |
| lines.append( | |
| f'{INDENT * 2}- DETECTED RECIPE CONDITION: {cond!r} (Step:' | |
| f' {step_nm!r})' | |
| ) | |
| if details.get('skipped_no_data'): | |
| lines.append( | |
| f'{INDENT * 2}- SKIPPED: \'skip processing because no data is found\'' | |
| ) | |
| if not details.get('pipeline_success') and details.get('child_steps'): | |
| lines.append(f'{INDENT * 2}- Substep breakdown:') | |
| for cs in details['child_steps']: | |
| c_name = cs['name'] | |
| c_status = cs['status'] | |
| lines.append(f'{INDENT * 3}* {c_name}: {c_status}') | |
| if details['merged_profdata_gcs_url']: | |
| m_url = details['merged_profdata_gcs_url'] | |
| lines.append(f'{INDENT * 2}- Merged Profdata GCS: {m_url}') | |
| formatted_file_report = format_file_coverage_report(file_report) | |
| if formatted_file_report: | |
| lines.append(formatted_file_report) | |
| return '\n'.join(lines) | |
| def main() -> None: | |
| """Parses CLI arguments, executes inspection phases, and prints report.""" | |
| parser = argparse.ArgumentParser( | |
| description='Inspect LUCI Code Coverage Build' | |
| ) | |
| parser.add_argument( | |
| '--build', | |
| required=True, | |
| help='LUCI Buildbucket URL or numeric build ID to inspect.', | |
| ) | |
| parser.add_argument( | |
| '--language', | |
| required=True, | |
| choices=get_supported_languages(), | |
| help='Programming language context (e.g. cpp, java, rust).', | |
| ) | |
| parser.add_argument( | |
| '--json', | |
| action='store_true', | |
| help='Output results as structured JSON.', | |
| ) | |
| parser.add_argument( | |
| '--files', | |
| nargs='*', | |
| default=[], | |
| help='Optional target file paths to extract coverage percentages for.', | |
| ) | |
| args = parser.parse_args() | |
| try: | |
| build_id = parse_build_id(args.build) | |
| build_data = run_bb_get(build_id) | |
| status_report = check_build_status(build_data) | |
| config_report = verify_configuration(build_data, language=args.language) | |
| pipeline_report = verify_coverage_pipeline(build_data) | |
| file_report = {} | |
| if args.files: | |
| file_report = extract_coverage_artifacts_for_files( | |
| pipeline_report, args.files | |
| ) | |
| combined_report = { | |
| 'status_check': status_report, | |
| 'config_verification': config_report, | |
| 'coverage_pipeline_verification': pipeline_report, | |
| 'target_file_coverage': file_report, | |
| } | |
| if args.json: | |
| output_text = json.dumps(combined_report, indent=2) | |
| else: | |
| output_text = format_inspection_report(combined_report, args.language) | |
| print(output_text) | |
| except Exception as e: | |
| print(f'ERROR: {e}', file=sys.stderr) | |
| sys.exit(1) | |
| if __name__ == '__main__': | |
| main() |