| #!/usr/bin/env python |
| |
| """ |
| Static Analyzer qualification infrastructure. |
| |
| The goal is to test the analyzer against different projects, |
| check for failures, compare results, and measure performance. |
| |
| Repository Directory will contain sources of the projects as well as the |
| information on how to build them and the expected output. |
| Repository Directory structure: |
| - ProjectMap file |
| - Historical Performance Data |
| - Project Dir1 |
| - ReferenceOutput |
| - Project Dir2 |
| - ReferenceOutput |
| .. |
| Note that the build tree must be inside the project dir. |
| |
| To test the build of the analyzer one would: |
| - Copy over a copy of the Repository Directory. (TODO: Prefer to ensure that |
| the build directory does not pollute the repository to min network |
| traffic). |
| - Build all projects, until error. Produce logs to report errors. |
| - Compare results. |
| |
| The files which should be kept around for failure investigations: |
| RepositoryCopy/Project DirI/ScanBuildResults |
| RepositoryCopy/Project DirI/run_static_analyzer.log |
| |
| Assumptions (TODO: shouldn't need to assume these.): |
| The script is being run from the Repository Directory. |
| The compiler for scan-build and scan-build are in the PATH. |
| export PATH=/Users/zaks/workspace/c2llvm/build/Release+Asserts/bin:$PATH |
| |
| For more logging, set the env variables: |
| zaks:TI zaks$ export CCC_ANALYZER_LOG=1 |
| zaks:TI zaks$ export CCC_ANALYZER_VERBOSE=1 |
| |
| The list of checkers tested are hardcoded in the Checkers variable. |
| For testing additional checkers, use the SA_ADDITIONAL_CHECKERS environment |
| variable. It should contain a comma separated list. |
| """ |
| import CmpRuns |
| import SATestUtils as utils |
| from ProjectMap import DownloadType, ProjectInfo |
| |
| import glob |
| import logging |
| import math |
| import multiprocessing |
| import os |
| import plistlib |
| import shutil |
| import sys |
| import threading |
| import time |
| import zipfile |
| |
| from queue import Queue |
| # mypy has problems finding InvalidFileException in the module |
| # and this is we can shush that false positive |
| from plistlib import InvalidFileException # type:ignore |
| from subprocess import CalledProcessError, check_call |
| from typing import Dict, IO, List, NamedTuple, Optional, TYPE_CHECKING, Tuple |
| |
| |
| ############################################################################### |
| # Helper functions. |
| ############################################################################### |
| |
| class StreamToLogger: |
| def __init__(self, logger: logging.Logger, |
| log_level: int = logging.INFO): |
| self.logger = logger |
| self.log_level = log_level |
| |
| def write(self, message: str): |
| # Rstrip in order not to write an extra newline. |
| self.logger.log(self.log_level, message.rstrip()) |
| |
| def flush(self): |
| pass |
| |
| def fileno(self) -> int: |
| return 0 |
| |
| |
| LOCAL = threading.local() |
| |
| |
| def init_logger(name: str): |
| # TODO: use debug levels for VERBOSE messages |
| logger = logging.getLogger(name) |
| logger.setLevel(logging.DEBUG) |
| LOCAL.stdout = StreamToLogger(logger, logging.INFO) |
| LOCAL.stderr = StreamToLogger(logger, logging.ERROR) |
| |
| |
| init_logger("main") |
| |
| |
| def stderr(message: str): |
| LOCAL.stderr.write(message) |
| |
| |
| def stdout(message: str): |
| LOCAL.stdout.write(message) |
| |
| |
| logging.basicConfig( |
| format='%(asctime)s:%(levelname)s:%(name)s: %(message)s') |
| |
| |
| ############################################################################### |
| # Configuration setup. |
| ############################################################################### |
| |
| |
| # Find Clang for static analysis. |
| if 'CC' in os.environ: |
| cc_candidate: Optional[str] = os.environ['CC'] |
| else: |
| cc_candidate = utils.which("clang", os.environ['PATH']) |
| if not cc_candidate: |
| stderr("Error: cannot find 'clang' in PATH") |
| sys.exit(1) |
| |
| CLANG = cc_candidate |
| |
| # Number of jobs. |
| MAX_JOBS = int(math.ceil(multiprocessing.cpu_count() * 0.75)) |
| |
| # Names of the project specific scripts. |
| # The script that downloads the project. |
| DOWNLOAD_SCRIPT = "download_project.sh" |
| # The script that needs to be executed before the build can start. |
| CLEANUP_SCRIPT = "cleanup_run_static_analyzer.sh" |
| # This is a file containing commands for scan-build. |
| BUILD_SCRIPT = "run_static_analyzer.cmd" |
| |
| # A comment in a build script which disables wrapping. |
| NO_PREFIX_CMD = "#NOPREFIX" |
| |
| # The log file name. |
| LOG_DIR_NAME = "Logs" |
| BUILD_LOG_NAME = "run_static_analyzer.log" |
| # Summary file - contains the summary of the failures. Ex: This info can be be |
| # displayed when buildbot detects a build failure. |
| NUM_OF_FAILURES_IN_SUMMARY = 10 |
| |
| # The scan-build result directory. |
| OUTPUT_DIR_NAME = "ScanBuildResults" |
| REF_PREFIX = "Ref" |
| |
| # The name of the directory storing the cached project source. If this |
| # directory does not exist, the download script will be executed. |
| # That script should create the "CachedSource" directory and download the |
| # project source into it. |
| CACHED_SOURCE_DIR_NAME = "CachedSource" |
| |
| # The name of the directory containing the source code that will be analyzed. |
| # Each time a project is analyzed, a fresh copy of its CachedSource directory |
| # will be copied to the PatchedSource directory and then the local patches |
| # in PATCHFILE_NAME will be applied (if PATCHFILE_NAME exists). |
| PATCHED_SOURCE_DIR_NAME = "PatchedSource" |
| |
| # The name of the patchfile specifying any changes that should be applied |
| # to the CachedSource before analyzing. |
| PATCHFILE_NAME = "changes_for_analyzer.patch" |
| |
| # The list of checkers used during analyzes. |
| # Currently, consists of all the non-experimental checkers, plus a few alpha |
| # checkers we don't want to regress on. |
| CHECKERS = ",".join([ |
| "alpha.unix.SimpleStream", |
| "alpha.security.taint", |
| "cplusplus.NewDeleteLeaks", |
| "core", |
| "cplusplus", |
| "deadcode", |
| "security", |
| "unix", |
| "osx", |
| "nullability" |
| ]) |
| |
| VERBOSE = 0 |
| |
| |
| ############################################################################### |
| # Test harness logic. |
| ############################################################################### |
| |
| |
| def run_cleanup_script(directory: str, build_log_file: IO): |
| """ |
| Run pre-processing script if any. |
| """ |
| cwd = os.path.join(directory, PATCHED_SOURCE_DIR_NAME) |
| script_path = os.path.join(directory, CLEANUP_SCRIPT) |
| |
| utils.run_script(script_path, build_log_file, cwd, |
| out=LOCAL.stdout, err=LOCAL.stderr, |
| verbose=VERBOSE) |
| |
| |
| class TestInfo(NamedTuple): |
| """ |
| Information about a project and settings for its analysis. |
| """ |
| project: ProjectInfo |
| override_compiler: bool = False |
| extra_analyzer_config: str = "" |
| extra_checkers: str = "" |
| is_reference_build: bool = False |
| strictness: int = 0 |
| |
| |
| # typing package doesn't have a separate type for Queue, but has a generic stub |
| # We still want to have a type-safe checked project queue, for this reason, |
| # we specify generic type for mypy. |
| # |
| # It is a common workaround for this situation: |
| # https://mypy.readthedocs.io/en/stable/common_issues.html#using-classes-that-are-generic-in-stubs-but-not-at-runtime |
| if TYPE_CHECKING: |
| TestQueue = Queue[TestInfo] # this is only processed by mypy |
| else: |
| TestQueue = Queue # this will be executed at runtime |
| |
| |
| class RegressionTester: |
| """ |
| A component aggregating all of the project testing. |
| """ |
| |
| def __init__(self, jobs: int, projects: List[ProjectInfo], |
| override_compiler: bool, extra_analyzer_config: str, |
| extra_checkers: str, |
| regenerate: bool, strictness: bool): |
| self.jobs = jobs |
| self.projects = projects |
| self.override_compiler = override_compiler |
| self.extra_analyzer_config = extra_analyzer_config |
| self.extra_checkers = extra_checkers |
| self.regenerate = regenerate |
| self.strictness = strictness |
| |
| def test_all(self) -> bool: |
| projects_to_test: List[TestInfo] = [] |
| |
| # Test the projects. |
| for project in self.projects: |
| projects_to_test.append( |
| TestInfo(project, |
| self.override_compiler, |
| self.extra_analyzer_config, |
| self.extra_checkers, |
| self.regenerate, self.strictness)) |
| if self.jobs <= 1: |
| return self._single_threaded_test_all(projects_to_test) |
| else: |
| return self._multi_threaded_test_all(projects_to_test) |
| |
| def _single_threaded_test_all(self, |
| projects_to_test: List[TestInfo]) -> bool: |
| """ |
| Run all projects. |
| :return: whether tests have passed. |
| """ |
| success = True |
| for project_info in projects_to_test: |
| tester = ProjectTester(project_info) |
| success &= tester.test() |
| return success |
| |
| def _multi_threaded_test_all(self, |
| projects_to_test: List[TestInfo]) -> bool: |
| """ |
| Run each project in a separate thread. |
| |
| This is OK despite GIL, as testing is blocked |
| on launching external processes. |
| |
| :return: whether tests have passed. |
| """ |
| tasks_queue = TestQueue() |
| |
| for project_info in projects_to_test: |
| tasks_queue.put(project_info) |
| |
| results_differ = threading.Event() |
| failure_flag = threading.Event() |
| |
| for _ in range(self.jobs): |
| T = TestProjectThread(tasks_queue, results_differ, failure_flag) |
| T.start() |
| |
| # Required to handle Ctrl-C gracefully. |
| while tasks_queue.unfinished_tasks: |
| time.sleep(0.1) # Seconds. |
| if failure_flag.is_set(): |
| stderr("Test runner crashed\n") |
| sys.exit(1) |
| return not results_differ.is_set() |
| |
| |
| class ProjectTester: |
| """ |
| A component aggregating testing for one project. |
| """ |
| |
| def __init__(self, test_info: TestInfo, silent: bool = False): |
| self.project = test_info.project |
| self.override_compiler = test_info.override_compiler |
| self.extra_analyzer_config = test_info.extra_analyzer_config |
| self.extra_checkers = test_info.extra_checkers |
| self.is_reference_build = test_info.is_reference_build |
| self.strictness = test_info.strictness |
| self.silent = silent |
| |
| def test(self) -> bool: |
| """ |
| Test a given project. |
| :return tests_passed: Whether tests have passed according |
| to the :param strictness: criteria. |
| """ |
| if not self.project.enabled: |
| self.out( |
| f" \n\n--- Skipping disabled project {self.project.name}\n") |
| return True |
| |
| self.out(f" \n\n--- Building project {self.project.name}\n") |
| |
| start_time = time.time() |
| |
| project_dir = self.get_project_dir() |
| self.vout(f" Build directory: {project_dir}.\n") |
| |
| # Set the build results directory. |
| output_dir = self.get_output_dir() |
| |
| self.build(project_dir, output_dir) |
| check_build(output_dir) |
| |
| if self.is_reference_build: |
| cleanup_reference_results(output_dir) |
| passed = True |
| else: |
| passed = run_cmp_results(project_dir, self.strictness) |
| |
| self.out(f"Completed tests for project {self.project.name} " |
| f"(time: {time.time() - start_time:.2f}).\n") |
| |
| return passed |
| |
| def get_project_dir(self) -> str: |
| return os.path.join(os.path.abspath(os.curdir), self.project.name) |
| |
| def get_output_dir(self) -> str: |
| if self.is_reference_build: |
| dirname = REF_PREFIX + OUTPUT_DIR_NAME |
| else: |
| dirname = OUTPUT_DIR_NAME |
| |
| return os.path.join(self.get_project_dir(), dirname) |
| |
| def build(self, directory: str, output_dir: str) -> Tuple[float, int]: |
| build_log_path = get_build_log_path(output_dir) |
| |
| self.out(f"Log file: {build_log_path}\n") |
| self.out(f"Output directory: {output_dir}\n") |
| |
| remove_log_file(output_dir) |
| |
| # Clean up scan build results. |
| if os.path.exists(output_dir): |
| self.vout(f" Removing old results: {output_dir}\n") |
| |
| shutil.rmtree(output_dir) |
| |
| assert(not os.path.exists(output_dir)) |
| os.makedirs(os.path.join(output_dir, LOG_DIR_NAME)) |
| |
| # Build and analyze the project. |
| with open(build_log_path, "w+") as build_log_file: |
| if self.project.mode == 1: |
| self._download_and_patch(directory, build_log_file) |
| run_cleanup_script(directory, build_log_file) |
| build_time, memory = self.scan_build(directory, output_dir, |
| build_log_file) |
| else: |
| build_time, memory = self.analyze_preprocessed(directory, |
| output_dir) |
| |
| if self.is_reference_build: |
| run_cleanup_script(directory, build_log_file) |
| normalize_reference_results(directory, output_dir, |
| self.project.mode) |
| |
| self.out(f"Build complete (time: {utils.time_to_str(build_time)}, " |
| f"peak memory: {utils.memory_to_str(memory)}). " |
| f"See the log for more details: {build_log_path}\n") |
| |
| return build_time, memory |
| |
| def scan_build(self, directory: str, output_dir: str, |
| build_log_file: IO) -> Tuple[float, int]: |
| """ |
| Build the project with scan-build by reading in the commands and |
| prefixing them with the scan-build options. |
| """ |
| build_script_path = os.path.join(directory, BUILD_SCRIPT) |
| if not os.path.exists(build_script_path): |
| stderr(f"Error: build script is not defined: " |
| f"{build_script_path}\n") |
| sys.exit(1) |
| |
| all_checkers = CHECKERS |
| if 'SA_ADDITIONAL_CHECKERS' in os.environ: |
| all_checkers = (all_checkers + ',' + |
| os.environ['SA_ADDITIONAL_CHECKERS']) |
| if self.extra_checkers != "": |
| all_checkers += "," + self.extra_checkers |
| |
| # Run scan-build from within the patched source directory. |
| cwd = os.path.join(directory, PATCHED_SOURCE_DIR_NAME) |
| |
| options = f"--use-analyzer '{CLANG}' " |
| options += f"-plist-html -o '{output_dir}' " |
| options += f"-enable-checker {all_checkers} " |
| options += "--keep-empty " |
| options += f"-analyzer-config '{self.generate_config()}' " |
| |
| if self.override_compiler: |
| options += "--override-compiler " |
| |
| extra_env: Dict[str, str] = {} |
| |
| execution_time = 0.0 |
| peak_memory = 0 |
| |
| try: |
| command_file = open(build_script_path, "r") |
| command_prefix = "scan-build " + options + " " |
| |
| for command in command_file: |
| command = command.strip() |
| |
| if len(command) == 0: |
| continue |
| |
| # Custom analyzer invocation specified by project. |
| # Communicate required information using environment variables |
| # instead. |
| if command == NO_PREFIX_CMD: |
| command_prefix = "" |
| extra_env['OUTPUT'] = output_dir |
| extra_env['CC'] = CLANG |
| extra_env['ANALYZER_CONFIG'] = self.generate_config() |
| continue |
| |
| if command.startswith("#"): |
| continue |
| |
| # If using 'make', auto imply a -jX argument |
| # to speed up analysis. xcodebuild will |
| # automatically use the maximum number of cores. |
| if (command.startswith("make ") or command == "make") and \ |
| "-j" not in command: |
| command += f" -j{MAX_JOBS}" |
| |
| command_to_run = command_prefix + command |
| |
| self.vout(f" Executing: {command_to_run}\n") |
| |
| time, mem = utils.check_and_measure_call( |
| command_to_run, cwd=cwd, |
| stderr=build_log_file, |
| stdout=build_log_file, |
| env=dict(os.environ, **extra_env), |
| shell=True) |
| |
| execution_time += time |
| peak_memory = max(peak_memory, mem) |
| |
| except CalledProcessError: |
| stderr("Error: scan-build failed. Its output was: \n") |
| build_log_file.seek(0) |
| shutil.copyfileobj(build_log_file, LOCAL.stderr) |
| sys.exit(1) |
| |
| return execution_time, peak_memory |
| |
| def analyze_preprocessed(self, directory: str, |
| output_dir: str) -> Tuple[float, int]: |
| """ |
| Run analysis on a set of preprocessed files. |
| """ |
| if os.path.exists(os.path.join(directory, BUILD_SCRIPT)): |
| stderr(f"Error: The preprocessed files project " |
| f"should not contain {BUILD_SCRIPT}\n") |
| raise Exception() |
| |
| prefix = CLANG + " --analyze " |
| |
| prefix += "--analyzer-output plist " |
| prefix += " -Xclang -analyzer-checker=" + CHECKERS |
| prefix += " -fcxx-exceptions -fblocks " |
| prefix += " -Xclang -analyzer-config " |
| prefix += f"-Xclang {self.generate_config()} " |
| |
| if self.project.mode == 2: |
| prefix += "-std=c++11 " |
| |
| plist_path = os.path.join(directory, output_dir, "date") |
| fail_path = os.path.join(plist_path, "failures") |
| os.makedirs(fail_path) |
| |
| execution_time = 0.0 |
| peak_memory = 0 |
| |
| for full_file_name in glob.glob(directory + "/*"): |
| file_name = os.path.basename(full_file_name) |
| failed = False |
| |
| # Only run the analyzes on supported files. |
| if utils.has_no_extension(file_name): |
| continue |
| if not utils.is_valid_single_input_file(file_name): |
| stderr(f"Error: Invalid single input file {full_file_name}.\n") |
| raise Exception() |
| |
| # Build and call the analyzer command. |
| plist_basename = os.path.join(plist_path, file_name) |
| output_option = f"-o '{plist_basename}.plist' " |
| command = f"{prefix}{output_option}'{file_name}'" |
| |
| log_path = os.path.join(fail_path, file_name + ".stderr.txt") |
| with open(log_path, "w+") as log_file: |
| try: |
| self.vout(f" Executing: {command}\n") |
| |
| time, mem = utils.check_and_measure_call( |
| command, cwd=directory, stderr=log_file, |
| stdout=log_file, shell=True) |
| |
| execution_time += time |
| peak_memory = max(peak_memory, mem) |
| |
| except CalledProcessError as e: |
| stderr(f"Error: Analyzes of {full_file_name} failed. " |
| f"See {log_file.name} for details. " |
| f"Error code {e.returncode}.\n") |
| failed = True |
| |
| # If command did not fail, erase the log file. |
| if not failed: |
| os.remove(log_file.name) |
| |
| return execution_time, peak_memory |
| |
| def generate_config(self) -> str: |
| out = "serialize-stats=true,stable-report-filename=true" |
| |
| if self.extra_analyzer_config: |
| out += "," + self.extra_analyzer_config |
| |
| return out |
| |
| def _download_and_patch(self, directory: str, build_log_file: IO): |
| """ |
| Download the project and apply the local patchfile if it exists. |
| """ |
| cached_source = os.path.join(directory, CACHED_SOURCE_DIR_NAME) |
| |
| # If the we don't already have the cached source, run the project's |
| # download script to download it. |
| if not os.path.exists(cached_source): |
| self._download(directory, build_log_file) |
| if not os.path.exists(cached_source): |
| stderr(f"Error: '{cached_source}' not found after download.\n") |
| exit(1) |
| |
| patched_source = os.path.join(directory, PATCHED_SOURCE_DIR_NAME) |
| |
| # Remove potentially stale patched source. |
| if os.path.exists(patched_source): |
| shutil.rmtree(patched_source) |
| |
| # Copy the cached source and apply any patches to the copy. |
| shutil.copytree(cached_source, patched_source, symlinks=True) |
| self._apply_patch(directory, build_log_file) |
| |
| def _download(self, directory: str, build_log_file: IO): |
| """ |
| Run the script to download the project, if it exists. |
| """ |
| if self.project.source == DownloadType.GIT: |
| self._download_from_git(directory, build_log_file) |
| elif self.project.source == DownloadType.ZIP: |
| self._unpack_zip(directory, build_log_file) |
| elif self.project.source == DownloadType.SCRIPT: |
| self._run_download_script(directory, build_log_file) |
| else: |
| raise ValueError( |
| f"Unknown source type '{self.project.source}' is found " |
| f"for the '{self.project.name}' project") |
| |
| def _download_from_git(self, directory: str, build_log_file: IO): |
| repo = self.project.origin |
| cached_source = os.path.join(directory, CACHED_SOURCE_DIR_NAME) |
| |
| check_call(f"git clone --recursive {repo} {cached_source}", |
| cwd=directory, stderr=build_log_file, |
| stdout=build_log_file, shell=True) |
| check_call(f"git checkout --quiet {self.project.commit}", |
| cwd=cached_source, stderr=build_log_file, |
| stdout=build_log_file, shell=True) |
| |
| def _unpack_zip(self, directory: str, build_log_file: IO): |
| zip_files = list(glob.glob(directory + "/*.zip")) |
| |
| if len(zip_files) == 0: |
| raise ValueError( |
| f"Couldn't find any zip files to unpack for the " |
| f"'{self.project.name}' project") |
| |
| if len(zip_files) > 1: |
| raise ValueError( |
| f"Couldn't decide which of the zip files ({zip_files}) " |
| f"for the '{self.project.name}' project to unpack") |
| |
| with zipfile.ZipFile(zip_files[0], "r") as zip_file: |
| zip_file.extractall(os.path.join(directory, |
| CACHED_SOURCE_DIR_NAME)) |
| |
| @staticmethod |
| def _run_download_script(directory: str, build_log_file: IO): |
| script_path = os.path.join(directory, DOWNLOAD_SCRIPT) |
| utils.run_script(script_path, build_log_file, directory, |
| out=LOCAL.stdout, err=LOCAL.stderr, |
| verbose=VERBOSE) |
| |
| def _apply_patch(self, directory: str, build_log_file: IO): |
| patchfile_path = os.path.join(directory, PATCHFILE_NAME) |
| patched_source = os.path.join(directory, PATCHED_SOURCE_DIR_NAME) |
| |
| if not os.path.exists(patchfile_path): |
| self.out(" No local patches.\n") |
| return |
| |
| self.out(" Applying patch.\n") |
| try: |
| check_call(f"patch -p1 < '{patchfile_path}'", |
| cwd=patched_source, |
| stderr=build_log_file, |
| stdout=build_log_file, |
| shell=True) |
| |
| except CalledProcessError: |
| stderr(f"Error: Patch failed. " |
| f"See {build_log_file.name} for details.\n") |
| sys.exit(1) |
| |
| def out(self, what: str): |
| if not self.silent: |
| stdout(what) |
| |
| def vout(self, what: str): |
| if VERBOSE >= 1: |
| self.out(what) |
| |
| |
| class TestProjectThread(threading.Thread): |
| def __init__(self, tasks_queue: TestQueue, |
| results_differ: threading.Event, |
| failure_flag: threading.Event): |
| """ |
| :param results_differ: Used to signify that results differ from |
| the canonical ones. |
| :param failure_flag: Used to signify a failure during the run. |
| """ |
| self.tasks_queue = tasks_queue |
| self.results_differ = results_differ |
| self.failure_flag = failure_flag |
| super().__init__() |
| |
| # Needed to gracefully handle interrupts with Ctrl-C |
| self.daemon = True |
| |
| def run(self): |
| while not self.tasks_queue.empty(): |
| try: |
| test_info = self.tasks_queue.get() |
| init_logger(test_info.project.name) |
| |
| tester = ProjectTester(test_info) |
| if not tester.test(): |
| self.results_differ.set() |
| |
| self.tasks_queue.task_done() |
| |
| except BaseException: |
| self.failure_flag.set() |
| raise |
| |
| |
| ############################################################################### |
| # Utility functions. |
| ############################################################################### |
| |
| |
| def check_build(output_dir: str): |
| """ |
| Given the scan-build output directory, checks if the build failed |
| (by searching for the failures directories). If there are failures, it |
| creates a summary file in the output directory. |
| |
| """ |
| # Check if there are failures. |
| failures = glob.glob(output_dir + "/*/failures/*.stderr.txt") |
| total_failed = len(failures) |
| |
| if total_failed == 0: |
| clean_up_empty_plists(output_dir) |
| clean_up_empty_folders(output_dir) |
| |
| plists = glob.glob(output_dir + "/*/*.plist") |
| stdout(f"Number of bug reports " |
| f"(non-empty plist files) produced: {len(plists)}\n") |
| return |
| |
| stderr("Error: analysis failed.\n") |
| stderr(f"Total of {total_failed} failures discovered.\n") |
| |
| if total_failed > NUM_OF_FAILURES_IN_SUMMARY: |
| stderr(f"See the first {NUM_OF_FAILURES_IN_SUMMARY} below.\n") |
| |
| for index, failed_log_path in enumerate(failures, start=1): |
| if index >= NUM_OF_FAILURES_IN_SUMMARY: |
| break |
| |
| stderr(f"\n-- Error #{index} -----------\n") |
| |
| with open(failed_log_path, "r") as failed_log: |
| shutil.copyfileobj(failed_log, LOCAL.stdout) |
| |
| if total_failed > NUM_OF_FAILURES_IN_SUMMARY: |
| stderr("See the results folder for more.") |
| |
| sys.exit(1) |
| |
| |
| def cleanup_reference_results(output_dir: str): |
| """ |
| Delete html, css, and js files from reference results. These can |
| include multiple copies of the benchmark source and so get very large. |
| """ |
| extensions = ["html", "css", "js"] |
| |
| for extension in extensions: |
| for file_to_rm in glob.glob(f"{output_dir}/*/*.{extension}"): |
| file_to_rm = os.path.join(output_dir, file_to_rm) |
| os.remove(file_to_rm) |
| |
| # Remove the log file. It leaks absolute path names. |
| remove_log_file(output_dir) |
| |
| |
| def run_cmp_results(directory: str, strictness: int = 0) -> bool: |
| """ |
| Compare the warnings produced by scan-build. |
| strictness defines the success criteria for the test: |
| 0 - success if there are no crashes or analyzer failure. |
| 1 - success if there are no difference in the number of reported bugs. |
| 2 - success if all the bug reports are identical. |
| |
| :return success: Whether tests pass according to the strictness |
| criteria. |
| """ |
| tests_passed = True |
| start_time = time.time() |
| |
| ref_dir = os.path.join(directory, REF_PREFIX + OUTPUT_DIR_NAME) |
| new_dir = os.path.join(directory, OUTPUT_DIR_NAME) |
| |
| # We have to go one level down the directory tree. |
| ref_list = glob.glob(ref_dir + "/*") |
| new_list = glob.glob(new_dir + "/*") |
| |
| # Log folders are also located in the results dir, so ignore them. |
| ref_log_dir = os.path.join(ref_dir, LOG_DIR_NAME) |
| if ref_log_dir in ref_list: |
| ref_list.remove(ref_log_dir) |
| new_list.remove(os.path.join(new_dir, LOG_DIR_NAME)) |
| |
| if len(ref_list) != len(new_list): |
| stderr(f"Mismatch in number of results folders: " |
| f"{ref_list} vs {new_list}") |
| sys.exit(1) |
| |
| # There might be more then one folder underneath - one per each scan-build |
| # command (Ex: one for configure and one for make). |
| if len(ref_list) > 1: |
| # Assume that the corresponding folders have the same names. |
| ref_list.sort() |
| new_list.sort() |
| |
| # Iterate and find the differences. |
| num_diffs = 0 |
| for ref_dir, new_dir in zip(ref_list, new_list): |
| assert(ref_dir != new_dir) |
| |
| if VERBOSE >= 1: |
| stdout(f" Comparing Results: {ref_dir} {new_dir}\n") |
| |
| patched_source = os.path.join(directory, PATCHED_SOURCE_DIR_NAME) |
| |
| ref_results = CmpRuns.ResultsDirectory(ref_dir) |
| new_results = CmpRuns.ResultsDirectory(new_dir, patched_source) |
| |
| # Scan the results, delete empty plist files. |
| num_diffs, reports_in_ref, reports_in_new = \ |
| CmpRuns.dump_scan_build_results_diff(ref_results, new_results, |
| delete_empty=False, |
| out=LOCAL.stdout) |
| |
| if num_diffs > 0: |
| stdout(f"Warning: {num_diffs} differences in diagnostics.\n") |
| |
| if strictness >= 2 and num_diffs > 0: |
| stdout("Error: Diffs found in strict mode (2).\n") |
| tests_passed = False |
| |
| elif strictness >= 1 and reports_in_ref != reports_in_new: |
| stdout("Error: The number of results are different " |
| " strict mode (1).\n") |
| tests_passed = False |
| |
| stdout(f"Diagnostic comparison complete " |
| f"(time: {time.time() - start_time:.2f}).\n") |
| |
| return tests_passed |
| |
| |
| def normalize_reference_results(directory: str, output_dir: str, |
| build_mode: int): |
| """ |
| Make the absolute paths relative in the reference results. |
| """ |
| for dir_path, _, filenames in os.walk(output_dir): |
| for filename in filenames: |
| if not filename.endswith('plist'): |
| continue |
| |
| plist = os.path.join(dir_path, filename) |
| with open(plist, "rb") as plist_file: |
| data = plistlib.load(plist_file) |
| path_prefix = directory |
| |
| if build_mode == 1: |
| path_prefix = os.path.join(directory, PATCHED_SOURCE_DIR_NAME) |
| |
| paths = [source[len(path_prefix) + 1:] |
| if source.startswith(path_prefix) else source |
| for source in data['files']] |
| data['files'] = paths |
| |
| # Remove transient fields which change from run to run. |
| for diagnostic in data['diagnostics']: |
| if 'HTMLDiagnostics_files' in diagnostic: |
| diagnostic.pop('HTMLDiagnostics_files') |
| |
| if 'clang_version' in data: |
| data.pop('clang_version') |
| |
| with open(plist, "wb") as plist_file: |
| plistlib.dump(data, plist_file) |
| |
| |
| def get_build_log_path(output_dir: str) -> str: |
| return os.path.join(output_dir, LOG_DIR_NAME, BUILD_LOG_NAME) |
| |
| |
| def remove_log_file(output_dir: str): |
| build_log_path = get_build_log_path(output_dir) |
| |
| # Clean up the log file. |
| if os.path.exists(build_log_path): |
| if VERBOSE >= 1: |
| stdout(f" Removing log file: {build_log_path}\n") |
| |
| os.remove(build_log_path) |
| |
| |
| def clean_up_empty_plists(output_dir: str): |
| """ |
| A plist file is created for each call to the analyzer(each source file). |
| We are only interested on the once that have bug reports, |
| so delete the rest. |
| """ |
| for plist in glob.glob(output_dir + "/*/*.plist"): |
| plist = os.path.join(output_dir, plist) |
| |
| try: |
| with open(plist, "rb") as plist_file: |
| data = plistlib.load(plist_file) |
| # Delete empty reports. |
| if not data['files']: |
| os.remove(plist) |
| continue |
| |
| except InvalidFileException as e: |
| stderr(f"Error parsing plist file {plist}: {str(e)}") |
| continue |
| |
| |
| def clean_up_empty_folders(output_dir: str): |
| """ |
| Remove empty folders from results, as git would not store them. |
| """ |
| subdirs = glob.glob(output_dir + "/*") |
| for subdir in subdirs: |
| if not os.listdir(subdir): |
| os.removedirs(subdir) |
| |
| |
| if __name__ == "__main__": |
| print("SATestBuild.py should not be used on its own.") |
| print("Please use 'SATest.py build' instead") |
| sys.exit(1) |