[fpv/script] add script to parse FPV result

This PR creates a script to parse fpv.log results.
It will output a results.hjson file.
This script is primarily used for dvsim

Signed-off-by: Cindy Chen <chencindy@google.com>
diff --git a/hw/formal/tools/jaspergold/parse-fpv-report.py b/hw/formal/tools/jaspergold/parse-fpv-report.py
new file mode 100644
index 0000000..8ae8226
--- /dev/null
+++ b/hw/formal/tools/jaspergold/parse-fpv-report.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+
+import argparse
+import logging as log
+import re
+from pathlib import Path
+from collections import OrderedDict
+import sys
+
+import hjson
+
+
+def extract_messages(str_buffer, patterns):
+    '''Extract messages matching patterns from str_buffer as a dictionary.
+
+    The patterns argument is a list of pairs, (key, pattern). Each pattern is a regex
+    and all matches in str_buffer are stored in a dictionary under the paired key.
+    '''
+    results = OrderedDict()
+    for key, pattern in patterns:
+        val = results.setdefault(key, [])
+        val += re.findall(pattern, str_buffer, flags=re.MULTILINE)
+
+    return results
+
+
+def extract_messages_count(str_buffer, patterns):
+    '''Extract messages matching patterns from full_file as a dictionary.
+
+    The patterns argument is a list of pairs, (key, pattern). Each pattern is a regex
+    and the total count of all matches in str_buffer are stored in a dictionary under
+    the paired key.
+    '''
+    results = OrderedDict()
+    for key, pattern in patterns:
+        results.setdefault(key, 0)
+        results[key] += len(re.findall(pattern, str_buffer, flags=re.MULTILINE))
+
+    return results
+
+
+def format_percentage(good, bad):
+    '''Return a percetange of good / (good + bad) with a format `100.00 %`.'''
+    denom = good + bad
+    pc = 100 * good / denom if denom else 0
+
+    return '{0:.2f} %'.format(round(pc, 2))
+
+
+def parse_fpv_message(str_buffer):
+    '''Parse error, warnings, and failed properties from the log file'''
+    err_warn_patterns = [("errors", r"^ERROR: .*"),
+                         ("errors", r"^\[ERROR.*"),
+                         ("warnings", r"^WARNING: .*"),
+                         ("warnings", r"^\[WARN.*"),
+                         ("cex", r"^\[\d+\]\s+(\S*)\s+cex.*"),
+                         ("undetermined", r"^\[\d+\]\s+(\S*)\s+undetermined.*"),
+                         ("unreachable", r"^\[\d+\]\s+(\S*)\s+unreachable.*")]
+    return extract_messages(str_buffer, err_warn_patterns)
+
+
+def parse_fpv_summary(str_buffer):
+    '''Count errors, warnings, and property status from the log file'''
+    message_patterns = [("errors", r"^ERROR: .*"),
+                        ("errors", r"^\[ERROR.*"),
+                        ("warnings", r"^WARNING: .*"),
+                        ("warnings", r"^\[WARN.*"),
+                        ("proven", r"^\[\d+\].*proven.*"),
+                        ("cex", r"^\[\d+\].*cex.*"),
+                        ("covered", r"^\[\d+\].*covered.*"),
+                        ("undetermined", r"^\[\d+\].*undetermined.*"),
+                        ("unreachable", r"^\[\d+\].*unreachable.*")]
+    fpv_summary = extract_messages_count(str_buffer, message_patterns)
+
+    fpv_summary["pass_rate"] = format_percentage(fpv_summary["proven"],
+                                                 fpv_summary["cex"] + fpv_summary["undetermined"])
+    fpv_summary["cov_rate"] = format_percentage(fpv_summary["covered"], fpv_summary["unreachable"])
+
+    return fpv_summary
+
+
+def get_results(logpath):
+    '''Parse FPV log file and extract info to a dictionary'''
+    try:
+        with Path(logpath).open() as f:
+            results = OrderedDict()
+            full_file = f.read()
+            results["messages"] = parse_fpv_message(full_file)
+            fpv_summary = parse_fpv_summary(full_file)
+            if fpv_summary:
+                results["fpv_summary"] = fpv_summary
+            return results
+
+    except IOError as err:
+        err_msg = 'IOError {}'.format(err)
+        log.error("[get_results] IOError %s", err)
+        return {'messages': {'errors': err_msg}}
+
+    return results
+
+
+def get_cov_results(logpath):
+    '''Parse FPV coverage information from the log file'''
+    try:
+        with Path(logpath).open() as f:
+            full_file = f.read()
+            coverage_patterns = [("stimuli", r"^\|\w+.*\|(\d*.\d\d)%.*\|\d*.\d\d%.*\|\d*.\d\d%.*"),
+                                 ("coi", r"^\|\w+.*\|\d*.\d\d%.*\|(\d*.\d\d)%.*\|\d*.\d\d%.*"),
+                                 ("proof", r"^\|\w+.*\|\d*.\d\d%.*\|\d*.\d\d%.*\|(\d*.\d\d)%.*")]
+            cov_results = extract_messages(full_file, coverage_patterns)
+            for key, item in cov_results.items():
+                if len(item) == 1:
+                    cov_results[key] = item[0] + " %"
+                else:
+                    cov_results[key] = "N/A"
+                    log.error("Parse %s coverage error. Expect one matching value, get %s",
+                              key, item)
+            return cov_results
+
+    except IOError as err:
+        log.error("[get_cov_results] IOError %s", err)
+        return None
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description=
+        '''This script parses the JasperGold FPV log to extract below information.
+
+        "messages": {
+          "errors"      : []
+          "warnings"    : []
+          "cex"         : ["property1", "property2"...],
+          "undetermined": [],
+          "unreachable" : [],
+        },
+        "fpv_summary": {
+          "errors"      : 0
+          "warnings"    : 2
+          "proven"      : 20,
+          "cex"         : 5,
+          "covered"     : 18,
+          "undetermined": 7,
+          "unreachable" : 2,
+          "pass_rate"   : "90 %",
+          "cover_rate"  : "90 %"
+        },
+        If coverage is enabled, this script will also parse the coverage result:
+        "fpv_coverage": {
+          stimuli: "90 %",
+          coi    : "90 %",
+          proof  : "80 %"
+        }
+        The script returns nonzero status if any errors or property failures including
+        "cex, undetermined, unreachable" are presented.
+        ''')
+    parser.add_argument('--logpath',
+                        type=str,
+                        default="fpv.log",
+                        help=('FPV log file path. Defaults to `fpv.log` '
+                              'under the current script directory.'))
+
+    parser.add_argument('--reppath',
+                        type=str,
+                        default="results.hjson",
+                        help=('Parsed output hjson file path. Defaults to '
+                              '`results.hjson` under the current script directory.'))
+
+    parser.add_argument('--cov',
+                        type=int,
+                        default=0,
+                        help=('Enable parsing coverage data. '
+                              'By default, coerage parsing is disabled.'))
+
+    args = parser.parse_args()
+
+    results = get_results(args.logpath)
+
+    if args.cov:
+        results["fpv_coverage"] = get_cov_results(args.logpath)
+
+    with Path(args.reppath).open("w") as results_file:
+        hjson.dump(results,
+                   results_file,
+                   ensure_ascii=False,
+                   for_json=True,
+                   use_decimal=True)
+
+    # return nonzero status if any errors or property failures are present
+    # TODO: currently allow FPV warnings
+    err_msgs = results["messages"]
+    n_errors = len(err_msgs["errors"])
+    n_failures = (len(err_msgs.get("cex")) + len(err_msgs.get("undetermined")) +
+                  len(err_msgs.get("unreachable")))
+    if n_errors > 0 or n_failures > 0:
+        log.info("Found %d FPV errors,  %d FPV property failures", n_errors, n_failures)
+        return 1
+
+    log.info("FPV logfile parsed succesfully")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())