[analyzer][tests] Understand when diagnostics change between builds

Before the patch `SATest compare`, produced quite obscure results
when something about the diagnostic have changed (i.e. its description
or the name of the corresponding checker) because it was simply two
lists of warnings, ADDED and REMOVED.  It was up to the developer
to match those warnings, understand that they are essentially the
same, and figure out what caused the difference.

This patch introduces another category of results: MODIFIED.
It tries to match new warnings against the old ones and prints out
clues on what is different between two builds.

Differential Revision: https://reviews.llvm.org/D85311
This commit is contained in:
Valeriy Savchenko 2020-08-05 17:32:24 +03:00
parent c6eb76093c
commit 6ddef92474

View File

@ -35,14 +35,16 @@ from math import log
from collections import defaultdict
from copy import copy
from enum import Enum
from typing import (Any, cast, Dict, List, NamedTuple, Optional, Sequence,
TextIO, TypeVar, Tuple, Union)
from typing import (Any, DefaultDict, Dict, List, NamedTuple, Optional,
Sequence, TextIO, TypeVar, Tuple, Union)
Number = Union[int, float]
Stats = Dict[str, Dict[str, Number]]
Plist = Dict[str, Any]
JSON = Dict[str, Any]
# Diff in a form: field -> (before, after)
JSONDiff = Dict[str, Tuple[str, str]]
# Type for generics
T = TypeVar('T')
@ -136,6 +138,9 @@ class AnalysisDiagnostic:
def get_description(self) -> str:
return self._data['description']
def get_location(self) -> str:
return f"{self.get_file_name()}:{self.get_line()}:{self.get_column()}"
def get_issue_identifier(self) -> str:
id = self.get_file_name() + "+"
@ -172,11 +177,32 @@ class AnalysisDiagnostic:
return f"{file_prefix}{funcname_postfix}:{line}:{col}" \
f", {self.get_category()}: {self.get_description()}"
KEY_FIELDS = ["check_name", "category", "description"]
def is_similar_to(self, other: "AnalysisDiagnostic") -> bool:
# We consider two diagnostics similar only if at least one
# of the key fields is the same in both diagnostics.
return len(self.get_diffs(other)) != len(self.KEY_FIELDS)
def get_diffs(self, other: "AnalysisDiagnostic") -> JSONDiff:
return {field: (self._data[field], other._data[field])
for field in self.KEY_FIELDS
if self._data[field] != other._data[field]}
# Note, the data format is not an API and may change from one analyzer
# version to another.
def get_raw_data(self) -> Plist:
return self._data
def __eq__(self, other: object) -> bool:
return hash(self) == hash(other)
def __ne__(self, other: object) -> bool:
return hash(self) != hash(other)
def __hash__(self) -> int:
return hash(self.get_issue_identifier())
class AnalysisRun:
def __init__(self, info: SingleRunInfo):
@ -283,12 +309,39 @@ def cmp_analysis_diagnostic(d):
return d.get_issue_identifier()
PresentInBoth = Tuple[AnalysisDiagnostic, AnalysisDiagnostic]
PresentOnlyInOld = Tuple[AnalysisDiagnostic, None]
PresentOnlyInNew = Tuple[None, AnalysisDiagnostic]
ComparisonResult = List[Union[PresentInBoth,
PresentOnlyInOld,
PresentOnlyInNew]]
AnalysisDiagnosticPair = Tuple[AnalysisDiagnostic, AnalysisDiagnostic]
class ComparisonResult:
def __init__(self):
self.present_in_both: List[AnalysisDiagnostic] = []
self.present_only_in_old: List[AnalysisDiagnostic] = []
self.present_only_in_new: List[AnalysisDiagnostic] = []
self.changed_between_new_and_old: List[AnalysisDiagnosticPair] = []
def add_common(self, issue: AnalysisDiagnostic):
self.present_in_both.append(issue)
def add_removed(self, issue: AnalysisDiagnostic):
self.present_only_in_old.append(issue)
def add_added(self, issue: AnalysisDiagnostic):
self.present_only_in_new.append(issue)
def add_changed(self, old_issue: AnalysisDiagnostic,
new_issue: AnalysisDiagnostic):
self.changed_between_new_and_old.append((old_issue, new_issue))
GroupedDiagnostics = DefaultDict[str, List[AnalysisDiagnostic]]
def get_grouped_diagnostics(diagnostics: List[AnalysisDiagnostic]
) -> GroupedDiagnostics:
result: GroupedDiagnostics = defaultdict(list)
for diagnostic in diagnostics:
result[diagnostic.get_location()].append(diagnostic)
return result
def compare_results(results_old: AnalysisRun, results_new: AnalysisRun,
@ -302,52 +355,79 @@ def compare_results(results_old: AnalysisRun, results_new: AnalysisRun,
each element {a,b} is None or a matching element from the respective run
"""
res: ComparisonResult = []
res = ComparisonResult()
# Map size_before -> size_after
path_difference_data: List[float] = []
# Quickly eliminate equal elements.
neq_old: List[AnalysisDiagnostic] = []
neq_new: List[AnalysisDiagnostic] = []
diags_old = get_grouped_diagnostics(results_old.diagnostics)
diags_new = get_grouped_diagnostics(results_new.diagnostics)
diags_old = copy(results_old.diagnostics)
diags_new = copy(results_new.diagnostics)
locations_old = set(diags_old.keys())
locations_new = set(diags_new.keys())
diags_old.sort(key=cmp_analysis_diagnostic)
diags_new.sort(key=cmp_analysis_diagnostic)
common_locations = locations_old & locations_new
while diags_old and diags_new:
a = diags_old.pop()
b = diags_new.pop()
for location in common_locations:
old = diags_old[location]
new = diags_new[location]
if a.get_issue_identifier() == b.get_issue_identifier():
if a.get_path_length() != b.get_path_length():
# Quadratic algorithms in this part are fine because 'old' and 'new'
# are most commonly of size 1.
for a in copy(old):
for b in copy(new):
if a.get_issue_identifier() == b.get_issue_identifier():
a_path_len = a.get_path_length()
b_path_len = b.get_path_length()
if histogram == HistogramType.RELATIVE:
path_difference_data.append(
float(a.get_path_length()) / b.get_path_length())
if a_path_len != b_path_len:
elif histogram == HistogramType.LOG_RELATIVE:
path_difference_data.append(
log(float(a.get_path_length()) / b.get_path_length()))
if histogram == HistogramType.RELATIVE:
path_difference_data.append(
float(a_path_len) / b_path_len)
elif histogram == HistogramType.ABSOLUTE:
path_difference_data.append(
a.get_path_length() - b.get_path_length())
elif histogram == HistogramType.LOG_RELATIVE:
path_difference_data.append(
log(float(a_path_len) / b_path_len))
res.append((a, b))
elif histogram == HistogramType.ABSOLUTE:
path_difference_data.append(
a_path_len - b_path_len)
elif a.get_issue_identifier() > b.get_issue_identifier():
diags_new.append(b)
neq_old.append(a)
res.add_common(a)
old.remove(a)
new.remove(b)
else:
diags_old.append(a)
neq_new.append(b)
for a in copy(old):
for b in copy(new):
if a.is_similar_to(b):
res.add_changed(a, b)
old.remove(a)
new.remove(b)
neq_old.extend(diags_old)
neq_new.extend(diags_new)
# Whatever is left in 'old' doesn't have a corresponding diagnostic
# in 'new', so we need to mark it as 'removed'.
for a in old:
res.add_removed(a)
# Whatever is left in 'new' doesn't have a corresponding diagnostic
# in 'old', so we need to mark it as 'added'.
for b in new:
res.add_added(b)
only_old_locations = locations_old - common_locations
for location in only_old_locations:
for a in diags_old[location]:
# These locations have been found only in the old build, so we
# need to mark all of therm as 'removed'
res.add_removed(a)
only_new_locations = locations_new - common_locations
for location in only_new_locations:
for b in diags_new[location]:
# These locations have been found only in the new build, so we
# need to mark all of therm as 'added'
res.add_added(b)
# FIXME: Add fuzzy matching. One simple and possible effective idea would
# be to bin the diagnostics, print them in a normalized form (based solely
@ -355,11 +435,6 @@ def compare_results(results_old: AnalysisRun, results_new: AnalysisRun,
# the basis for matching. This has the nice property that we don't depend
# in any way on the diagnostic format.
for a in neq_old:
res.append((a, None))
for b in neq_new:
res.append((None, b))
if histogram:
from matplotlib import pyplot
pyplot.hist(path_difference_data, bins=100)
@ -476,47 +551,55 @@ def dump_scan_build_results_diff(dir_old: ResultsDirectory,
# Open the verbose log, if given.
if verbose_log:
auxLog: Optional[TextIO] = open(verbose_log, "w")
aux_log: Optional[TextIO] = open(verbose_log, "w")
else:
auxLog = None
aux_log = None
diff = compare_results(results_old, results_new, histogram)
found_diffs = 0
total_added = 0
total_removed = 0
total_modified = 0
for res in diff:
old, new = res
if old is None:
# TODO: mypy still doesn't understand that old and new can't be
# both Nones, we should introduce a better type solution
new = cast(AnalysisDiagnostic, new)
out.write(f"ADDED: {new.get_readable_name()}\n")
found_diffs += 1
total_added += 1
if auxLog:
auxLog.write(f"('ADDED', {new.get_readable_name()}, "
f"{new.get_html_report()})\n")
for new in diff.present_only_in_new:
out.write(f"ADDED: {new.get_readable_name()}\n\n")
found_diffs += 1
total_added += 1
if aux_log:
aux_log.write(f"('ADDED', {new.get_readable_name()}, "
f"{new.get_html_report()})\n")
elif new is None:
out.write(f"REMOVED: {old.get_readable_name()}\n")
found_diffs += 1
total_removed += 1
if auxLog:
auxLog.write(f"('REMOVED', {old.get_readable_name()}, "
f"{old.get_html_report()})\n")
else:
pass
for old in diff.present_only_in_old:
out.write(f"REMOVED: {old.get_readable_name()}\n\n")
found_diffs += 1
total_removed += 1
if aux_log:
aux_log.write(f"('REMOVED', {old.get_readable_name()}, "
f"{old.get_html_report()})\n")
for old, new in diff.changed_between_new_and_old:
out.write(f"MODIFIED: {old.get_readable_name()}\n")
found_diffs += 1
total_modified += 1
diffs = old.get_diffs(new)
str_diffs = [f" '{key}' changed: "
f"'{old_value}' -> '{new_value}'"
for key, (old_value, new_value) in diffs.items()]
out.write(",\n".join(str_diffs) + "\n\n")
if aux_log:
aux_log.write(f"('MODIFIED', {old.get_readable_name()}, "
f"{old.get_html_report()})\n")
total_reports = len(results_new.diagnostics)
out.write(f"TOTAL REPORTS: {total_reports}\n")
out.write(f"TOTAL ADDED: {total_added}\n")
out.write(f"TOTAL REMOVED: {total_removed}\n")
out.write(f"TOTAL MODIFIED: {total_modified}\n")
if auxLog:
auxLog.write(f"('TOTAL NEW REPORTS', {total_reports})\n")
auxLog.write(f"('TOTAL DIFFERENCES', {found_diffs})\n")
auxLog.close()
if aux_log:
aux_log.write(f"('TOTAL NEW REPORTS', {total_reports})\n")
aux_log.write(f"('TOTAL DIFFERENCES', {found_diffs})\n")
aux_log.close()
# TODO: change to NamedTuple
return found_diffs, len(results_old.diagnostics), \