llvm-project
661 строка · 20.1 Кб
1#!/usr/bin/env python
2
3"""
4CmpRuns - A simple tool for comparing two static analyzer runs to determine
5which reports have been added, removed, or changed.
6
7This is designed to support automated testing using the static analyzer, from
8two perspectives:
91. To monitor changes in the static analyzer's reports on real code bases,
10for regression testing.
11
122. For use by end users who want to integrate regular static analyzer testing
13into a buildbot like environment.
14
15Usage:
16
17# Load the results of both runs, to obtain lists of the corresponding
18# AnalysisDiagnostic objects.
19#
20resultsA = load_results_from_single_run(singleRunInfoA, delete_empty)
21resultsB = load_results_from_single_run(singleRunInfoB, delete_empty)
22
23# Generate a relation from diagnostics in run A to diagnostics in run B
24# to obtain a list of triples (a, b, confidence).
25diff = compare_results(resultsA, resultsB)
26
27"""
28import json29import os30import plistlib31import re32import sys33
34from math import log35from collections import defaultdict36from copy import copy37from enum import Enum38from typing import (39Any,40DefaultDict,41Dict,42List,43NamedTuple,44Optional,45Sequence,46Set,47TextIO,48TypeVar,49Tuple,50Union,51)
52
53
54Number = Union[int, float]55Stats = Dict[str, Dict[str, Number]]56Plist = Dict[str, Any]57JSON = Dict[str, Any]58# Diff in a form: field -> (before, after)
59JSONDiff = Dict[str, Tuple[str, str]]60# Type for generics
61T = TypeVar("T")62
63STATS_REGEXP = re.compile(r"Statistics: (\{.+\})", re.MULTILINE | re.DOTALL)64
65
66class Colors:67"""68Color for terminal highlight.
69"""
70
71RED = "\x1b[2;30;41m"72GREEN = "\x1b[6;30;42m"73CLEAR = "\x1b[0m"74
75
76class HistogramType(str, Enum):77RELATIVE = "relative"78LOG_RELATIVE = "log-relative"79ABSOLUTE = "absolute"80
81
82class ResultsDirectory(NamedTuple):83path: str84root: str = ""85
86
87class SingleRunInfo:88"""89Information about analysis run:
90path - the analysis output directory
91root - the name of the root directory, which will be disregarded when
92determining the source file name
93"""
94
95def __init__(self, results: ResultsDirectory, verbose_log: Optional[str] = None):96self.path = results.path97self.root = results.root.rstrip("/\\")98self.verbose_log = verbose_log99
100
101class AnalysisDiagnostic:102def __init__(103self, data: Plist, report: "AnalysisReport", html_report: Optional[str]104):105self._data = data106self._loc = self._data["location"]107self._report = report108self._html_report = html_report109self._report_size = len(self._data["path"])110
111def get_file_name(self) -> str:112root = self._report.run.root113file_name = self._report.files[self._loc["file"]]114
115if file_name.startswith(root) and len(root) > 0:116return file_name[len(root) + 1 :]117
118return file_name119
120def get_root_file_name(self) -> str:121path = self._data["path"]122
123if not path:124return self.get_file_name()125
126p = path[0]127if "location" in p:128file_index = p["location"]["file"]129else: # control edge130file_index = path[0]["edges"][0]["start"][0]["file"]131
132out = self._report.files[file_index]133root = self._report.run.root134
135if out.startswith(root):136return out[len(root) :]137
138return out139
140def get_line(self) -> int:141return self._loc["line"]142
143def get_column(self) -> int:144return self._loc["col"]145
146def get_path_length(self) -> int:147return self._report_size148
149def get_category(self) -> str:150return self._data["category"]151
152def get_description(self) -> str:153return self._data["description"]154
155def get_location(self) -> str:156return f"{self.get_file_name()}:{self.get_line()}:{self.get_column()}"157
158def get_issue_identifier(self) -> str:159id = self.get_file_name() + "+"160
161if "issue_context" in self._data:162id += self._data["issue_context"] + "+"163
164if "issue_hash_content_of_line_in_context" in self._data:165id += str(self._data["issue_hash_content_of_line_in_context"])166
167return id168
169def get_html_report(self) -> str:170if self._html_report is None:171return " "172
173return os.path.join(self._report.run.path, self._html_report)174
175def get_readable_name(self) -> str:176if "issue_context" in self._data:177funcname_postfix = "#" + self._data["issue_context"]178else:179funcname_postfix = ""180
181root_filename = self.get_root_file_name()182file_name = self.get_file_name()183
184if root_filename != file_name:185file_prefix = f"[{root_filename}] {file_name}"186else:187file_prefix = root_filename188
189line = self.get_line()190col = self.get_column()191return (192f"{file_prefix}{funcname_postfix}:{line}:{col}"193f", {self.get_category()}: {self.get_description()}"194)195
196KEY_FIELDS = ["check_name", "category", "description"]197
198def is_similar_to(self, other: "AnalysisDiagnostic") -> bool:199# We consider two diagnostics similar only if at least one200# of the key fields is the same in both diagnostics.201return len(self.get_diffs(other)) != len(self.KEY_FIELDS)202
203def get_diffs(self, other: "AnalysisDiagnostic") -> JSONDiff:204return {205field: (self._data[field], other._data[field])206for field in self.KEY_FIELDS207if self._data[field] != other._data[field]208}209
210# Note, the data format is not an API and may change from one analyzer211# version to another.212def get_raw_data(self) -> Plist:213return self._data214
215def __eq__(self, other: object) -> bool:216return hash(self) == hash(other)217
218def __ne__(self, other: object) -> bool:219return hash(self) != hash(other)220
221def __hash__(self) -> int:222return hash(self.get_issue_identifier())223
224
225class AnalysisRun:226def __init__(self, info: SingleRunInfo):227self.path = info.path228self.root = info.root229self.info = info230self.reports: List[AnalysisReport] = []231# Cumulative list of all diagnostics from all the reports.232self.diagnostics: List[AnalysisDiagnostic] = []233self.clang_version: Optional[str] = None234self.raw_stats: List[JSON] = []235
236def get_clang_version(self) -> Optional[str]:237return self.clang_version238
239def read_single_file(self, path: str, delete_empty: bool):240with open(path, "rb") as plist_file:241data = plistlib.load(plist_file)242
243if "statistics" in data:244self.raw_stats.append(json.loads(data["statistics"]))245data.pop("statistics")246
247# We want to retrieve the clang version even if there are no248# reports. Assume that all reports were created using the same249# clang version (this is always true and is more efficient).250if "clang_version" in data:251if self.clang_version is None:252self.clang_version = data.pop("clang_version")253else:254data.pop("clang_version")255
256# Ignore/delete empty reports.257if not data["files"]:258if delete_empty:259os.remove(path)260return261
262# Extract the HTML reports, if they exists.263htmlFiles = []264for d in data["diagnostics"]:265if "HTMLDiagnostics_files" in d:266# FIXME: Why is this named files, when does it have multiple267# files?268assert len(d["HTMLDiagnostics_files"]) == 1269htmlFiles.append(d.pop("HTMLDiagnostics_files")[0])270else:271htmlFiles.append(None)272
273report = AnalysisReport(self, data.pop("files"))274# Python 3.10 offers zip(..., strict=True). The following assertion275# mimics it.276assert len(data["diagnostics"]) == len(htmlFiles)277diagnostics = [278AnalysisDiagnostic(d, report, h)279for d, h in zip(data.pop("diagnostics"), htmlFiles)280]281
282assert not data283
284report.diagnostics.extend(diagnostics)285self.reports.append(report)286self.diagnostics.extend(diagnostics)287
288
289class AnalysisReport:290def __init__(self, run: AnalysisRun, files: List[str]):291self.run = run292self.files = files293self.diagnostics: List[AnalysisDiagnostic] = []294
295
296def load_results(297results: ResultsDirectory,298delete_empty: bool = True,299verbose_log: Optional[str] = None,300) -> AnalysisRun:301"""302Backwards compatibility API.
303"""
304return load_results_from_single_run(305SingleRunInfo(results, verbose_log), delete_empty306)307
308
309def load_results_from_single_run(310info: SingleRunInfo, delete_empty: bool = True311) -> AnalysisRun:312"""313# Load results of the analyzes from a given output folder.
314# - info is the SingleRunInfo object
315# - delete_empty specifies if the empty plist files should be deleted
316
317"""
318path = info.path319run = AnalysisRun(info)320
321if os.path.isfile(path):322run.read_single_file(path, delete_empty)323else:324for dirpath, dirnames, filenames in os.walk(path):325for f in filenames:326if not f.endswith("plist"):327continue328
329p = os.path.join(dirpath, f)330run.read_single_file(p, delete_empty)331
332return run333
334
335def cmp_analysis_diagnostic(d):336return d.get_issue_identifier()337
338
339AnalysisDiagnosticPair = Tuple[AnalysisDiagnostic, AnalysisDiagnostic]340
341
342class ComparisonResult:343def __init__(self):344self.present_in_both: List[AnalysisDiagnostic] = []345self.present_only_in_old: List[AnalysisDiagnostic] = []346self.present_only_in_new: List[AnalysisDiagnostic] = []347self.changed_between_new_and_old: List[AnalysisDiagnosticPair] = []348
349def add_common(self, issue: AnalysisDiagnostic):350self.present_in_both.append(issue)351
352def add_removed(self, issue: AnalysisDiagnostic):353self.present_only_in_old.append(issue)354
355def add_added(self, issue: AnalysisDiagnostic):356self.present_only_in_new.append(issue)357
358def add_changed(self, old_issue: AnalysisDiagnostic, new_issue: AnalysisDiagnostic):359self.changed_between_new_and_old.append((old_issue, new_issue))360
361
362GroupedDiagnostics = DefaultDict[str, List[AnalysisDiagnostic]]363
364
365def get_grouped_diagnostics(366diagnostics: List[AnalysisDiagnostic],367) -> GroupedDiagnostics:368result: GroupedDiagnostics = defaultdict(list)369for diagnostic in diagnostics:370result[diagnostic.get_location()].append(diagnostic)371return result372
373
374def compare_results(375results_old: AnalysisRun,376results_new: AnalysisRun,377histogram: Optional[HistogramType] = None,378) -> ComparisonResult:379"""380compare_results - Generate a relation from diagnostics in run A to
381diagnostics in run B.
382
383The result is the relation as a list of triples (a, b) where
384each element {a,b} is None or a matching element from the respective run
385"""
386
387res = ComparisonResult()388
389# Map size_before -> size_after390path_difference_data: List[float] = []391
392diags_old = get_grouped_diagnostics(results_old.diagnostics)393diags_new = get_grouped_diagnostics(results_new.diagnostics)394
395locations_old = set(diags_old.keys())396locations_new = set(diags_new.keys())397
398common_locations = locations_old & locations_new399
400for location in common_locations:401old = diags_old[location]402new = diags_new[location]403
404# Quadratic algorithms in this part are fine because 'old' and 'new'405# are most commonly of size 1.406common: Set[AnalysisDiagnostic] = set()407for a in old:408for b in new:409if a.get_issue_identifier() == b.get_issue_identifier():410a_path_len = a.get_path_length()411b_path_len = b.get_path_length()412
413if a_path_len != b_path_len:414
415if histogram == HistogramType.RELATIVE:416path_difference_data.append(float(a_path_len) / b_path_len)417
418elif histogram == HistogramType.LOG_RELATIVE:419path_difference_data.append(420log(float(a_path_len) / b_path_len)421)422
423elif histogram == HistogramType.ABSOLUTE:424path_difference_data.append(a_path_len - b_path_len)425
426res.add_common(b)427common.add(a)428
429old = filter_issues(old, common)430new = filter_issues(new, common)431common = set()432
433for a in old:434for b in new:435if a.is_similar_to(b):436res.add_changed(a, b)437common.add(a)438common.add(b)439
440old = filter_issues(old, common)441new = filter_issues(new, common)442
443# Whatever is left in 'old' doesn't have a corresponding diagnostic444# in 'new', so we need to mark it as 'removed'.445for a in old:446res.add_removed(a)447
448# Whatever is left in 'new' doesn't have a corresponding diagnostic449# in 'old', so we need to mark it as 'added'.450for b in new:451res.add_added(b)452
453only_old_locations = locations_old - common_locations454for location in only_old_locations:455for a in diags_old[location]:456# These locations have been found only in the old build, so we457# need to mark all of therm as 'removed'458res.add_removed(a)459
460only_new_locations = locations_new - common_locations461for location in only_new_locations:462for b in diags_new[location]:463# These locations have been found only in the new build, so we464# need to mark all of therm as 'added'465res.add_added(b)466
467# FIXME: Add fuzzy matching. One simple and possible effective idea would468# be to bin the diagnostics, print them in a normalized form (based solely469# on the structure of the diagnostic), compute the diff, then use that as470# the basis for matching. This has the nice property that we don't depend471# in any way on the diagnostic format.472
473if histogram:474from matplotlib import pyplot475
476pyplot.hist(path_difference_data, bins=100)477pyplot.show()478
479return res480
481
482def filter_issues(483origin: List[AnalysisDiagnostic], to_remove: Set[AnalysisDiagnostic]484) -> List[AnalysisDiagnostic]:485return [diag for diag in origin if diag not in to_remove]486
487
488def compute_percentile(values: Sequence[T], percentile: float) -> T:489"""490Return computed percentile.
491"""
492return sorted(values)[int(round(percentile * len(values) + 0.5)) - 1]493
494
495def derive_stats(results: AnalysisRun) -> Stats:496# Assume all keys are the same in each statistics bucket.497combined_data = defaultdict(list)498
499# Collect data on paths length.500for report in results.reports:501for diagnostic in report.diagnostics:502combined_data["PathsLength"].append(diagnostic.get_path_length())503
504for stat in results.raw_stats:505for key, value in stat.items():506combined_data[str(key)].append(value)507
508combined_stats: Stats = {}509
510for key, values in combined_data.items():511combined_stats[key] = {512"max": max(values),513"min": min(values),514"mean": sum(values) / len(values),515"90th %tile": compute_percentile(values, 0.9),516"95th %tile": compute_percentile(values, 0.95),517"median": sorted(values)[len(values) // 2],518"total": sum(values),519}520
521return combined_stats522
523
524# TODO: compare_results decouples comparison from the output, we should
525# do it here as well
526def compare_stats(527results_old: AnalysisRun, results_new: AnalysisRun, out: TextIO = sys.stdout528):529stats_old = derive_stats(results_old)530stats_new = derive_stats(results_new)531
532old_keys = set(stats_old.keys())533new_keys = set(stats_new.keys())534keys = sorted(old_keys & new_keys)535
536for key in keys:537out.write(f"{key}\n")538
539nested_keys = sorted(set(stats_old[key]) & set(stats_new[key]))540
541for nested_key in nested_keys:542val_old = float(stats_old[key][nested_key])543val_new = float(stats_new[key][nested_key])544
545report = f"{val_old:.3f} -> {val_new:.3f}"546
547# Only apply highlighting when writing to TTY and it's not Windows548if out.isatty() and os.name != "nt":549if val_new != 0:550ratio = (val_new - val_old) / val_new551if ratio < -0.2:552report = Colors.GREEN + report + Colors.CLEAR553elif ratio > 0.2:554report = Colors.RED + report + Colors.CLEAR555
556out.write(f"\t {nested_key} {report}\n")557
558removed_keys = old_keys - new_keys559if removed_keys:560out.write(f"REMOVED statistics: {removed_keys}\n")561
562added_keys = new_keys - old_keys563if added_keys:564out.write(f"ADDED statistics: {added_keys}\n")565
566out.write("\n")567
568
569def dump_scan_build_results_diff(570dir_old: ResultsDirectory,571dir_new: ResultsDirectory,572delete_empty: bool = True,573out: TextIO = sys.stdout,574show_stats: bool = False,575stats_only: bool = False,576histogram: Optional[HistogramType] = None,577verbose_log: Optional[str] = None,578):579"""580Compare directories with analysis results and dump results.
581
582:param delete_empty: delete empty plist files
583:param out: buffer to dump comparison results to.
584:param show_stats: compare execution stats as well.
585:param stats_only: compare ONLY execution stats.
586:param histogram: optional histogram type to plot path differences.
587:param verbose_log: optional path to an additional log file.
588"""
589results_old = load_results(dir_old, delete_empty, verbose_log)590results_new = load_results(dir_new, delete_empty, verbose_log)591
592if show_stats or stats_only:593compare_stats(results_old, results_new)594if stats_only:595return596
597# Open the verbose log, if given.598if verbose_log:599aux_log: Optional[TextIO] = open(verbose_log, "w")600else:601aux_log = None602
603diff = compare_results(results_old, results_new, histogram)604found_diffs = 0605total_added = 0606total_removed = 0607total_modified = 0608
609for new in diff.present_only_in_new:610out.write(f"ADDED: {new.get_readable_name()}\n\n")611found_diffs += 1612total_added += 1613if aux_log:614aux_log.write(615f"('ADDED', {new.get_readable_name()}, " f"{new.get_html_report()})\n"616)617
618for old in diff.present_only_in_old:619out.write(f"REMOVED: {old.get_readable_name()}\n\n")620found_diffs += 1621total_removed += 1622if aux_log:623aux_log.write(624f"('REMOVED', {old.get_readable_name()}, " f"{old.get_html_report()})\n"625)626
627for old, new in diff.changed_between_new_and_old:628out.write(f"MODIFIED: {old.get_readable_name()}\n")629found_diffs += 1630total_modified += 1631diffs = old.get_diffs(new)632str_diffs = [633f" '{key}' changed: " f"'{old_value}' -> '{new_value}'"634for key, (old_value, new_value) in diffs.items()635]636out.write(",\n".join(str_diffs) + "\n\n")637if aux_log:638aux_log.write(639f"('MODIFIED', {old.get_readable_name()}, "640f"{old.get_html_report()})\n"641)642
643total_reports = len(results_new.diagnostics)644out.write(f"TOTAL REPORTS: {total_reports}\n")645out.write(f"TOTAL ADDED: {total_added}\n")646out.write(f"TOTAL REMOVED: {total_removed}\n")647out.write(f"TOTAL MODIFIED: {total_modified}\n")648
649if aux_log:650aux_log.write(f"('TOTAL NEW REPORTS', {total_reports})\n")651aux_log.write(f"('TOTAL DIFFERENCES', {found_diffs})\n")652aux_log.close()653
654# TODO: change to NamedTuple655return found_diffs, len(results_old.diagnostics), len(results_new.diagnostics)656
657
658if __name__ == "__main__":659print("CmpRuns.py should not be used on its own.")660print("Please use 'SATest.py compare' instead")661sys.exit(1)662