Skip to content

Commit

Permalink
Merge pull request #147 from melexis/coverity-regex-checker
Browse files Browse the repository at this point in the history
Add code quality report to CoverityChecker
  • Loading branch information
JasperCraeghs authored Sep 26, 2024
2 parents 2620523 + f896b0e commit 57b5c02
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 19 deletions.
64 changes: 60 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,13 @@ between your branch and master, which it then forwards to ``cov-run-desktop``:
cov-run-desktop --text-output-style=oneline `git diff --name-only --ignore-submodules master`
You can pipe the results to logfile, which you pass to warnings-plugin, or you use
the ``--command`` argument and execute the ``cov-run-desktop`` through

.. code-block:: bash
# command line log file
mlx-warnings cov-run-desktop-output.txt --coverity
mlx-warnings --coverity cov-run-desktop-output.txt
# command line command execution
mlx-warnings --coverity --command <commandforcoverity>
Expand All @@ -212,6 +211,52 @@ the ``--command`` argument and execute the ``cov-run-desktop`` through
python -m mlx.warnings --coverity --command <commandforcoverity>
We utilize `cov-run-desktop` in the following manner, where the output is saved in `coverity.log`:

.. code-block:: bash
cov-run-desktop --text-output-style=oneline --exit1-if-defects false --triage-attribute-regex "classification" ".*" <coverity_files> | tee coverity.log
Subsequently, we process the `coverity.log` file with the mlx-warnings plugin.
The plugin uses a configuration file (`warnings_coverity.yml`) and produces two outputs:
a text file (`warnings_coverity.txt`) and a code quality JSON file (`coverity_code_quality.json`).

.. code-block:: bash
mlx-warnings --config warnings_coverity.yml -o warnings_coverity.txt -C coverity_code_quality.json coverity.log
This is an example of the configuration file:

.. code-block:: yaml
sphinx:
enabled: false
doxygen:
enabled: false
junit:
enabled: false
xmlrunner:
enabled: false
coverity:
enabled: true
intentional:
max: -1
bug:
max: 0
pending:
max: 0
false_positive:
max: -1
robot:
enabled: false
polyspace:
enabled: false
For each classification, a minimum and maximum can be given.

.. note::
The warnings-plugin counts only one warning if there are multiple warnings for the same CID.

Parse for JUnit Failures
------------------------

Expand Down Expand Up @@ -392,8 +437,14 @@ The values for 'min' and 'max' can be set with environment variables via a
},
"coverity": {
"enabled": false,
"min": 0,
"max": 0
"bug": {
"min": 0,
"max": 0
},
"pending": {
"min": 0,
"max": 0
}
},
"robot": {
"enabled": false,
Expand Down Expand Up @@ -534,6 +585,11 @@ Polyspace
`column title <Exporting Polyspace Results_>`_ in lowercase as the variable name.
The default template is ``Polyspace: $check``.

Coverity
Named groups of the regular expression can be used as variables.
Useful names are: `checker` and `classification`.
The default template is ``Coverity: $checker``.

Other
The template should contain ``$description``, which is the default.

Expand Down
2 changes: 1 addition & 1 deletion src/mlx/warnings/polyspace_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ def add_code_quality_finding(self, row):
finding["description"] = description
exclude = ("new", "status", "severity", "comment", "key")
row_without_key = [value for key, value in row.items() if key not in exclude]
finding["fingerprint"] = hashlib.md5(str(row_without_key).encode('utf8')).hexdigest()
finding["fingerprint"] = hashlib.md5(str(row_without_key).encode('utf-8')).hexdigest()
self.cq_findings.append(finding)

def check(self, content):
Expand Down
206 changes: 197 additions & 9 deletions src/mlx/warnings/regex_checker.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import hashlib
import os
import re
from pathlib import Path
from string import Template

from .exceptions import WarningsConfigError
from .warnings_checker import WarningsChecker

DOXYGEN_WARNING_REGEX = r"(?:(?P<path1>(?:[/.]|[A-Za-z]).+?):(?P<line1>-?\d+):\s*(?P<severity1>[Ww]arning|[Ee]rror)|<.+>:(?P<line2>-?\d+)(?::\s*(?P<severity2>[Ww]arning|[Ee]rror))?): (?P<description1>.+(?:(?!\s*([Nn]otice|[Ww]arning|[Ee]rror): )[^/<\n][^:\n][^/\n].+)*)|\s*\b(?P<severity3>[Nn]otice|[Ww]arning|[Ee]rror): (?!notes)(?P<description2>.+)\n?"
Expand All @@ -13,7 +16,7 @@
PYTHON_XMLRUNNER_REGEX = r"(\s*(?P<severity1>ERROR|FAILED) (\[\d+\.\d{3}s\]: \s*(?P<description1>.+)))\n?"
xmlrunner_pattern = re.compile(PYTHON_XMLRUNNER_REGEX)

COVERITY_WARNING_REGEX = r"(?:((?:[/.]|[A-Za-z]).+?):(-?\d+):) (CID) \d+ \(#(?P<curr>\d+) of (?P<max>\d+)\): (?P<checker>.+\)): (?P<classification>\w+), *(.+)\n?"
COVERITY_WARNING_REGEX = r"(?P<path>[\d\w/\\/-_]+\.\w+)(:(?P<line>\d+)(:(?P<column>\d+))?)?: ?CID \d+ \(#(?P<curr>\d+) of (?P<max>\d+)\): (?P<checker>.+): (?P<classification>[\w ]+),.+"
coverity_pattern = re.compile(COVERITY_WARNING_REGEX)


Expand Down Expand Up @@ -49,6 +52,11 @@ def check(self, content):
self.add_code_quality_finding(match)

def add_code_quality_finding(self, match):
'''Add code quality finding
Args:
match (re.Match): The regex match
'''
finding = {
"severity": "major",
"location": {
Expand Down Expand Up @@ -88,14 +96,60 @@ def add_code_quality_finding(self, match):
lineno = 1
finding["location"]["lines"]["begin"] = lineno
break
finding["fingerprint"] = hashlib.md5(str(finding).encode('utf8')).hexdigest()
finding["fingerprint"] = hashlib.md5(str(finding).encode('utf-8')).hexdigest()
self.cq_findings.append(finding)


class CoverityChecker(RegexChecker):
name = 'coverity'
pattern = coverity_pattern
CLASSIFICATION = "Unclassified"

def __init__(self, verbose=False):
super().__init__(verbose)
self._cq_description_template = Template('Coverity: $checker')
self.checkers = {}

@property
def counted_warnings(self):
''' List: list of counted warnings (str) '''
all_counted_warnings = []
for checker in self.checkers.values():
all_counted_warnings.extend(checker.counted_warnings)
return all_counted_warnings

@property
def cq_description_template(self):
''' Template: string.Template instance based on the configured template string '''
return self._cq_description_template

@cq_description_template.setter
def cq_description_template(self, template_obj):
self._cq_description_template = template_obj

def return_count(self):
''' Getter function for the amount of warnings found
Returns:
int: Number of warnings found
'''
self.count = 0
for checker in self.checkers.values():
self.count += checker.return_count()
return self.count

def return_check_limits(self):
''' Function for checking whether the warning count is within the configured limits
Returns:
int: 0 if the amount of warnings is within limits, the count of warnings otherwise
(or 1 in case of a count of 0 warnings)
'''
count = 0
for checker in self.checkers.values():
print(f"Counted failures for classification {checker.classification!r}")
count += checker.return_check_limits()
print(f"total warnings = {count}")
return count

def check(self, content):
'''
Expand All @@ -107,12 +161,146 @@ def check(self, content):
'''
matches = re.finditer(self.pattern, content)
for match in matches:
if (match.group('curr') == match.group('max')) and \
(match.group('classification') in self.CLASSIFICATION):
self.count += 1
match_string = match.group(0).strip()
self.counted_warnings.append(match_string)
self.print_when_verbose(match_string)
if (classification := match.group("classification").lower()) in self.checkers:
self.checkers[classification].check(match)
else:
checker = CoverityClassificationChecker(classification=classification, verbose=self.verbose)
self.checkers[classification] = checker
checker.cq_enabled = self.cq_enabled
checker.exclude_patterns = self.exclude_patterns
checker.cq_description_template = self.cq_description_template
checker.cq_default_path = self.cq_default_path
checker.check(match)

def parse_config(self, config):
"""Process configuration
Args:
config (dict): Content of configuration file
"""
config.pop("enabled")
if value := config.pop("cq_description_template", None):
self.cq_description_template = Template(value)
if value := config.pop("cq_default_path", None):
self.cq_default_path = value
if value := config.pop("exclude", None):
self.add_patterns(value, self.exclude_patterns)
for classification, checker_config in config.items():
classification_key = classification.lower().replace("_", " ")
if classification_key in CoverityClassificationChecker.SEVERITY_MAP:
checker = CoverityClassificationChecker(classification=classification_key, verbose=self.verbose)
if maximum := checker_config.get("max", 0):
checker.maximum = int(maximum)
if minimum := checker_config.get("min", 0):
checker.minimum = int(minimum)
checker.cq_findings = self.cq_findings # share object with sub-checkers
self.checkers[classification_key] = checker
else:
print(f"WARNING: Unrecognized classification {classification!r}")

for checker in self.checkers.values():
checker.cq_enabled = self.cq_enabled
checker.exclude_patterns = self.exclude_patterns
checker.cq_description_template = self.cq_description_template
checker.cq_default_path = self.cq_default_path


class CoverityClassificationChecker(WarningsChecker):
SEVERITY_MAP = {
'false positive': 'info',
'intentional': 'info',
'bug': 'major',
'unclassified': 'major',
'pending': 'critical',
}

def __init__(self, classification, **kwargs):
"""Initialize the CoverityClassificationChecker:
Args:
classification (str): The coverity classification
"""
super().__init__(**kwargs)
self.classification = classification

@property
def cq_description_template(self):
''' Template: string.Template instance based on the configured template string '''
return self._cq_description_template

@cq_description_template.setter
def cq_description_template(self, template_obj):
self._cq_description_template = template_obj

def return_count(self):
''' Getter function for the amount of warnings found
Returns:
int: Number of warnings found
'''
return self.count

def add_code_quality_finding(self, match):
'''Add code quality finding
Args:
match (re.Match): The regex match
'''
finding = {
"severity": "major",
"location": {
"path": self.cq_default_path,
"positions": {
"begin": {
"line": 1,
"column": 1
}
}
}
}
groups = {name: result for name, result in match.groupdict().items() if result}
try:
description = self.cq_description_template.substitute(os.environ, **groups)
except KeyError as err:
raise WarningsConfigError(f"Failed to find environment variable from configuration value "
f"'cq_description_template': {err}") from err
if classification_raw := groups.get("classification"):
finding["severity"] = self.SEVERITY_MAP[classification_raw.lower()]
if "path" in groups:
path = Path(groups["path"])
if path.is_absolute():
try:
path = path.relative_to(Path.cwd())
except ValueError as err:
raise ValueError("Failed to convert abolute path to relative path for Code Quality report: "
f"{err}") from err
finding["location"]["path"] = str(path)
for group_name in ("line", "column"):
if group_name in groups:
try:
finding["location"]["positions"]["begin"][group_name] = int(groups[group_name], 0)
except (TypeError, ValueError):
pass

finding["description"] = description
finding["fingerprint"] = hashlib.md5(str(match.group(0).strip()).encode('utf-8')).hexdigest()
self.cq_findings.append(finding)

def check(self, content):
'''
Function for counting the number of warnings, but adopted for Coverity output.
Multiple warnings for the same CID are counted as one.
Args:
content (re.Match): The regex match
'''
match_string = content.group(0).strip()
if not self._is_excluded(match_string) and (content.group('curr') == content.group('max')):
self.count += 1
self.counted_warnings.append(match_string)
self.print_when_verbose(match_string)
if self.cq_enabled:
self.add_code_quality_finding(content)


class DoxyChecker(RegexChecker):
Expand Down
2 changes: 1 addition & 1 deletion src/mlx/warnings/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def activate_checker(self, checker):
Args:
checker (WarningsChecker): checker object
'''
checker.cq_enabled = self.cq_enabled and checker.name in ('doxygen', 'sphinx', 'xmlrunner', 'polyspace')
checker.cq_enabled = self.cq_enabled and checker.name in ('doxygen', 'sphinx', 'xmlrunner', 'polyspace', 'coverity')
self.activated_checkers[checker.name] = checker

def activate_checker_name(self, name):
Expand Down
3 changes: 3 additions & 0 deletions src/mlx/warnings/warnings_checker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import abc
from math import inf
import os
import re
from string import Template
Expand Down Expand Up @@ -78,6 +79,8 @@ def maximum(self):

@maximum.setter
def maximum(self, maximum):
if maximum == -1:
maximum = inf
if self._minimum > maximum:
raise ValueError("Invalid argument: maximum limit must be higher than minimum limit ({min}); cannot "
"set {max}.".format(max=maximum, min=self._minimum))
Expand Down
Loading

0 comments on commit 57b5c02

Please sign in to comment.