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:
9  1. To monitor changes in the static analyzer's reports on real code bases, for
10     regression testing.
11
12  2. For use by end users who want to integrate regular static analyzer testing
13     into a buildbot like environment.
14
15Usage:
16
17    # Load the results of both runs, to obtain lists of the corresponding
18    # AnalysisDiagnostic objects.
19    #
20    resultsA = loadResultsFromSingleRun(singleRunInfoA, deleteEmpty)
21    resultsB = loadResultsFromSingleRun(singleRunInfoB, deleteEmpty)
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).
25    diff = compareResults(resultsA, resultsB)
26
27"""
28
29import os
30import plistlib
31import CmpRuns
32
33# Information about analysis run:
34# path - the analysis output directory
35# root - the name of the root directory, which will be disregarded when
36# determining the source file name
37class SingleRunInfo:
38    def __init__(self, path, root="", verboseLog=None):
39        self.path = path
40        self.root = root
41        self.verboseLog = verboseLog
42
43class AnalysisDiagnostic:
44    def __init__(self, data, report, htmlReport):
45        self._data = data
46        self._loc = self._data['location']
47        self._report = report
48        self._htmlReport = htmlReport
49
50    def getFileName(self):
51        root = self._report.run.root
52        fileName = self._report.files[self._loc['file']]
53        if fileName.startswith(root) :
54            return fileName[len(root):]
55        return fileName
56
57    def getLine(self):
58        return self._loc['line']
59
60    def getColumn(self):
61        return self._loc['col']
62
63    def getCategory(self):
64        return self._data['category']
65
66    def getDescription(self):
67        return self._data['description']
68
69    def getIssueIdentifier(self) :
70        id = self.getFileName() + "+"
71        if 'issue_context' in self._data :
72          id += self._data['issue_context'] + "+"
73        if 'issue_hash' in self._data :
74          id += str(self._data['issue_hash'])
75        return id
76
77    def getReport(self):
78        if self._htmlReport is None:
79            return " "
80        return os.path.join(self._report.run.path, self._htmlReport)
81
82    def getReadableName(self):
83        return '%s:%d:%d, %s: %s' % (self.getFileName(), self.getLine(),
84                                     self.getColumn(), self.getCategory(),
85                                     self.getDescription())
86
87    # Note, the data format is not an API and may change from one analyzer
88    # version to another.
89    def getRawData(self):
90        return self._data
91
92class multidict:
93    def __init__(self, elts=()):
94        self.data = {}
95        for key,value in elts:
96            self[key] = value
97
98    def __getitem__(self, item):
99        return self.data[item]
100    def __setitem__(self, key, value):
101        if key in self.data:
102            self.data[key].append(value)
103        else:
104            self.data[key] = [value]
105    def items(self):
106        return self.data.items()
107    def values(self):
108        return self.data.values()
109    def keys(self):
110        return self.data.keys()
111    def __len__(self):
112        return len(self.data)
113    def get(self, key, default=None):
114        return self.data.get(key, default)
115
116class CmpOptions:
117    def __init__(self, verboseLog=None, rootA="", rootB=""):
118        self.rootA = rootA
119        self.rootB = rootB
120        self.verboseLog = verboseLog
121
122class AnalysisReport:
123    def __init__(self, run, files):
124        self.run = run
125        self.files = files
126        self.diagnostics = []
127
128class AnalysisRun:
129    def __init__(self, info):
130        self.path = info.path
131        self.root = info.root
132        self.info = info
133        self.reports = []
134        # Cumulative list of all diagnostics from all the reports.
135        self.diagnostics = []
136        self.clang_version = None
137
138    def getClangVersion(self):
139        return self.clang_version
140
141    def readSingleFile(self, p, deleteEmpty):
142        data = plistlib.readPlist(p)
143
144        # We want to retrieve the clang version even if there are no
145        # reports. Assume that all reports were created using the same
146        # clang version (this is always true and is more efficient).
147        if 'clang_version' in data:
148            if self.clang_version == None:
149                self.clang_version = data.pop('clang_version')
150            else:
151                data.pop('clang_version')
152
153        # Ignore/delete empty reports.
154        if not data['files']:
155            if deleteEmpty == True:
156                os.remove(p)
157            return
158
159        # Extract the HTML reports, if they exists.
160        if 'HTMLDiagnostics_files' in data['diagnostics'][0]:
161            htmlFiles = []
162            for d in data['diagnostics']:
163                # FIXME: Why is this named files, when does it have multiple
164                # files?
165                assert len(d['HTMLDiagnostics_files']) == 1
166                htmlFiles.append(d.pop('HTMLDiagnostics_files')[0])
167        else:
168            htmlFiles = [None] * len(data['diagnostics'])
169
170        report = AnalysisReport(self, data.pop('files'))
171        diagnostics = [AnalysisDiagnostic(d, report, h)
172                       for d,h in zip(data.pop('diagnostics'),
173                                      htmlFiles)]
174
175        assert not data
176
177        report.diagnostics.extend(diagnostics)
178        self.reports.append(report)
179        self.diagnostics.extend(diagnostics)
180
181
182# Backward compatibility API.
183def loadResults(path, opts, root = "", deleteEmpty=True):
184    return loadResultsFromSingleRun(SingleRunInfo(path, root, opts.verboseLog),
185                                    deleteEmpty)
186
187# Load results of the analyzes from a given output folder.
188# - info is the SingleRunInfo object
189# - deleteEmpty specifies if the empty plist files should be deleted
190def loadResultsFromSingleRun(info, deleteEmpty=True):
191    path = info.path
192    run = AnalysisRun(info)
193
194    if os.path.isfile(path):
195        run.readSingleFile(path, deleteEmpty)
196    else:
197        for (dirpath, dirnames, filenames) in os.walk(path):
198            for f in filenames:
199                if (not f.endswith('plist')):
200                    continue
201                p = os.path.join(dirpath, f)
202                run.readSingleFile(p, deleteEmpty)
203
204    return run
205
206def cmpAnalysisDiagnostic(d) :
207    return d.getIssueIdentifier()
208
209def compareResults(A, B):
210    """
211    compareResults - Generate a relation from diagnostics in run A to
212    diagnostics in run B.
213
214    The result is the relation as a list of triples (a, b, confidence) where
215    each element {a,b} is None or an element from the respective run, and
216    confidence is a measure of the match quality (where 0 indicates equality,
217    and None is used if either element is None).
218    """
219
220    res = []
221
222    # Quickly eliminate equal elements.
223    neqA = []
224    neqB = []
225    eltsA = list(A.diagnostics)
226    eltsB = list(B.diagnostics)
227    eltsA.sort(key = cmpAnalysisDiagnostic)
228    eltsB.sort(key = cmpAnalysisDiagnostic)
229    while eltsA and eltsB:
230        a = eltsA.pop()
231        b = eltsB.pop()
232        if (a.getIssueIdentifier() == b.getIssueIdentifier()) :
233            res.append((a, b, 0))
234        elif a.getIssueIdentifier() > b.getIssueIdentifier():
235            eltsB.append(b)
236            neqA.append(a)
237        else:
238            eltsA.append(a)
239            neqB.append(b)
240    neqA.extend(eltsA)
241    neqB.extend(eltsB)
242
243    # FIXME: Add fuzzy matching. One simple and possible effective idea would be
244    # to bin the diagnostics, print them in a normalized form (based solely on
245    # the structure of the diagnostic), compute the diff, then use that as the
246    # basis for matching. This has the nice property that we don't depend in any
247    # way on the diagnostic format.
248
249    for a in neqA:
250        res.append((a, None, None))
251    for b in neqB:
252        res.append((None, b, None))
253
254    return res
255
256def dumpScanBuildResultsDiff(dirA, dirB, opts, deleteEmpty=True):
257    # Load the run results.
258    resultsA = loadResults(dirA, opts, opts.rootA, deleteEmpty)
259    resultsB = loadResults(dirB, opts, opts.rootB, deleteEmpty)
260
261    # Open the verbose log, if given.
262    if opts.verboseLog:
263        auxLog = open(opts.verboseLog, "wb")
264    else:
265        auxLog = None
266
267    diff = compareResults(resultsA, resultsB)
268    foundDiffs = 0
269    for res in diff:
270        a,b,confidence = res
271        if a is None:
272            print "ADDED: %r" % b.getReadableName()
273            foundDiffs += 1
274            if auxLog:
275                print >>auxLog, ("('ADDED', %r, %r)" % (b.getReadableName(),
276                                                        b.getReport()))
277        elif b is None:
278            print "REMOVED: %r" % a.getReadableName()
279            foundDiffs += 1
280            if auxLog:
281                print >>auxLog, ("('REMOVED', %r, %r)" % (a.getReadableName(),
282                                                          a.getReport()))
283        elif confidence:
284            print "CHANGED: %r to %r" % (a.getReadableName(),
285                                         b.getReadableName())
286            foundDiffs += 1
287            if auxLog:
288                print >>auxLog, ("('CHANGED', %r, %r, %r, %r)"
289                                 % (a.getReadableName(),
290                                    b.getReadableName(),
291                                    a.getReport(),
292                                    b.getReport()))
293        else:
294            pass
295
296    TotalReports = len(resultsB.diagnostics)
297    print "TOTAL REPORTS: %r" % TotalReports
298    print "TOTAL DIFFERENCES: %r" % foundDiffs
299    if auxLog:
300        print >>auxLog, "('TOTAL NEW REPORTS', %r)" % TotalReports
301        print >>auxLog, "('TOTAL DIFFERENCES', %r)" % foundDiffs
302
303    return foundDiffs
304
305def main():
306    from optparse import OptionParser
307    parser = OptionParser("usage: %prog [options] [dir A] [dir B]")
308    parser.add_option("", "--rootA", dest="rootA",
309                      help="Prefix to ignore on source files for directory A",
310                      action="store", type=str, default="")
311    parser.add_option("", "--rootB", dest="rootB",
312                      help="Prefix to ignore on source files for directory B",
313                      action="store", type=str, default="")
314    parser.add_option("", "--verbose-log", dest="verboseLog",
315                      help="Write additional information to LOG [default=None]",
316                      action="store", type=str, default=None,
317                      metavar="LOG")
318    (opts, args) = parser.parse_args()
319
320    if len(args) != 2:
321        parser.error("invalid number of arguments")
322
323    dirA,dirB = args
324
325    dumpScanBuildResultsDiff(dirA, dirB, opts)
326
327if __name__ == '__main__':
328    main()
329