From 423b8c8a9483351d53ee17d5fa8e7c2225cd2cda Mon Sep 17 00:00:00 2001
From: Valentin Perrelle <valentin.perrelle@cea.fr>
Date: Mon, 18 Mar 2019 18:21:42 +0100
Subject: [PATCH] [fcscript] Add a script to monitor and summarize multiple
 analyses

---
 Makefile                                     |  18 +-
 bin/frama-c-script                           |   9 +
 headers/header_spec.txt                      |   9 +-
 share/analysis-scripts/benchmark_database.py | 177 +++++++++++++
 share/analysis-scripts/clone.sh              |  91 +++++++
 share/analysis-scripts/find_fun.py           |   2 +-
 share/analysis-scripts/frama_c_results.py    |  73 ++++++
 share/analysis-scripts/git_utils.py          |  59 +++++
 share/analysis-scripts/list_files.py         |   2 +-
 share/analysis-scripts/results_display.py    | 255 +++++++++++++++++++
 share/analysis-scripts/summary.py            | 219 ++++++++++++++++
 11 files changed, 906 insertions(+), 8 deletions(-)
 create mode 100644 share/analysis-scripts/benchmark_database.py
 create mode 100755 share/analysis-scripts/clone.sh
 create mode 100644 share/analysis-scripts/frama_c_results.py
 create mode 100644 share/analysis-scripts/git_utils.py
 create mode 100644 share/analysis-scripts/results_display.py
 create mode 100755 share/analysis-scripts/summary.py

diff --git a/Makefile b/Makefile
index d007948e544..c451aa21fec 100644
--- a/Makefile
+++ b/Makefile
@@ -251,16 +251,21 @@ DISTRIB_FILES:=\
       Changelog config.h.in						\
       VERSION VERSION_CODENAME $(wildcard licenses/*)                   \
       $(LIBC_FILES)							\
+      share/analysis-scripts/benchmark_database.py                      \
       share/analysis-scripts/cmd-dep.sh                                 \
       share/analysis-scripts/concat-csv.sh                              \
+      share/analysis-scripts/clone.sh                                   \
       $(wildcard share/analysis-scripts/examples/*)                     \
       share/analysis-scripts/find_fun.py                                \
       share/analysis-scripts/flamegraph.pl                              \
       share/analysis-scripts/frama-c.mk                                 \
+      share/analysis-scripts/frama_c_results.py                         \
+      share/analysis-scripts/git_utils.py                               \
       share/analysis-scripts/list_files.py                              \
       share/analysis-scripts/parse-coverage.sh                          \
-      share/analysis-scripts/summary.sh                                 \
       share/analysis-scripts/README.md                                  \
+      share/analysis-scripts/results_display.py                         \
+      share/analysis-scripts/summary.py                                 \
       share/analysis-scripts/template.mk                                \
       $(wildcard share/emacs/*.el) share/autocomplete_frama-c           \
       share/_frama-c                                                    \
@@ -1898,15 +1903,20 @@ install:: install-lib
 	  share/configure.ac share/autocomplete_frama-c share/_frama-c \
 	  $(FRAMAC_DATADIR)
 	$(MKDIR) $(FRAMAC_DATADIR)/analysis-scripts
-	$(CP) share/analysis-scripts/cmd-dep.sh \
+	$(CP) share/analysis-scripts/benchmark_database.py \
+	  share/analysis-scripts/cmd-dep.sh \
 	  share/analysis-scripts/concat-csv.sh \
+	  share/analysis-scripts/clone.sh \
 	  share/analysis-scripts/find_fun.py \
 	  share/analysis-scripts/flamegraph.pl \
 	  share/analysis-scripts/frama-c.mk \
+	  share/analysis-scripts/frama_c_results.py \
+	  share/analysis-scripts/git_utils.py \
+	  share/analysis-scripts/list_files.py \
 	  share/analysis-scripts/parse-coverage.sh \
 	  share/analysis-scripts/README.md \
-	  share/analysis-scripts/list_files.py \
-	  share/analysis-scripts/summary.sh \
+	  share/analysis-scripts/results_display.py \
+	  share/analysis-scripts/summary.py \
 	  share/analysis-scripts/template.mk \
 	  $(FRAMAC_DATADIR)/analysis-scripts
 	$(MKDIR) $(FRAMAC_DATADIR)/analysis-scripts/examples
diff --git a/bin/frama-c-script b/bin/frama-c-script
index b46c6944edc..b797957eeb5 100755
--- a/bin/frama-c-script
+++ b/bin/frama-c-script
@@ -54,6 +54,11 @@ if [ $# -lt 1 ]; then
    echo "      Lists files in [dirs] declaring or defining <function-name>"
    echo "      (defaults to PWD + /usr/include)."
    echo "      Heuristics-based: neither correct nor complete."
+   echo ""
+   echo "  - summary [options]"
+   echo "      Monitors and summarizes multiple analyses dispatched by a Makefile"
+   echo "      in the current PWD."
+   echo "      Use $0 summary --help for more informations."
    exit
 fi
 
@@ -201,6 +206,10 @@ case "$command" in
         shift;
         flamegraph "$@";
         ;;
+    "summary")
+        shift;
+        ${FRAMAC_SHARE}/analysis-scripts/summary.py "$@";
+        ;;
     *)
         echo "error: unrecognized command: $command"
 esac
diff --git a/headers/header_spec.txt b/headers/header_spec.txt
index 5666f1680d5..5960673ee93 100644
--- a/headers/header_spec.txt
+++ b/headers/header_spec.txt
@@ -113,8 +113,10 @@ ptests/.gitignore: .ignore
 ptests/.merlin: .ignore
 ptests/ptests.ml: CEA_LGPL
 share/_frama-c: CEA_LGPL
+share/analysis-scripts/benchmark_database.py: .ignore
+share/analysis-scripts/clone.sh: .ignore
 share/analysis-scripts/frama-c.mk: CEA_LGPL
-share/analysis-scripts/README.md: .ignore
+share/analysis-scripts/frama_c_results.py: .ignore
 share/analysis-scripts/cmd-dep.sh: .ignore
 share/analysis-scripts/concat-csv.sh: .ignore
 share/analysis-scripts/examples/example.c: .ignore
@@ -124,9 +126,12 @@ share/analysis-scripts/examples/example-slevel.mk: .ignore
 share/analysis-scripts/examples/Makefile: .ignore
 share/analysis-scripts/find_fun.py: .ignore
 share/analysis-scripts/flamegraph.pl: CDDL
+share/analysis-scripts/git_utils.py: .ignore
 share/analysis-scripts/list_files.py: .ignore
 share/analysis-scripts/parse-coverage.sh: .ignore
-share/analysis-scripts/summary.sh: .ignore
+share/analysis-scripts/README.md: .ignore
+share/analysis-scripts/results_display.py: .ignore
+share/analysis-scripts/summary.py: .ignore
 share/analysis-scripts/template.mk: .ignore
 share/autocomplete_frama-c: CEA_LGPL
 share/Makefile.clean: CEA_LGPL
diff --git a/share/analysis-scripts/benchmark_database.py b/share/analysis-scripts/benchmark_database.py
new file mode 100644
index 00000000000..0f74d2c8084
--- /dev/null
+++ b/share/analysis-scripts/benchmark_database.py
@@ -0,0 +1,177 @@
+##########################################################################
+#                                                                        #
+#  This file is part of Frama-C.                                         #
+#                                                                        #
+#  Copyright (C) 2007-2019                                               #
+#    CEA (Commissariat à l'énergie atomique et aux énergies              #
+#         alternatives)                                                  #
+#                                                                        #
+#  you can redistribute it and/or modify it under the terms of the GNU   #
+#  Lesser General Public License as published by the Free Software       #
+#  Foundation, version 2.1.                                              #
+#                                                                        #
+#  It is distributed in the hope that it will be useful,                 #
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of        #
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         #
+#  GNU Lesser General Public License for more details.                   #
+#                                                                        #
+#  See the GNU Lesser General Public License version 2.1                 #
+#  for more details (enclosed in the file licenses/LGPLv2.1).            #
+#                                                                        #
+##########################################################################
+
+import time
+import os
+import csv
+import sqlite3
+
+import git_utils
+
+def dict_factory(cursor, row):
+    d = {}
+    for idx, col in enumerate(cursor.description):
+        d[col[0]] = row[idx]
+    return d
+
+
+class Database:
+    inserted_targets = {}
+
+    def __init__(self, benchmark_tag, benchmark_comment, gitdir, analyzer_rev, reference_rev):
+        self.benchmark_tag = benchmark_tag
+        self.benchmark_comment = benchmark_comment
+        if analyzer_rev is None:
+            self.analyzer_hash = None
+            self.analyzer = None
+        else:
+            self.analyzer_hash = git_utils.rev_parse(gitdir, analyzer_rev)
+            self.analyzer = git_utils.name_rev(gitdir, analyzer_rev)
+        self.reference_hash = git_utils.rev_parse(gitdir, reference_rev)
+        self.connection = sqlite3.connect('benchmark-results.db')
+        self.connection.row_factory = dict_factory
+        self.setup_rdb()
+        self.reference_results = self.query_rdb(self.reference_hash)
+
+    def update(self, results):
+        if not self.benchmark_tag is None:
+            for result in results:
+                inserted = result["target"] in self.inserted_targets
+                if result["up_to_date"] and not inserted:
+                    self.insert(result)
+                    self.inserted_targets[result["target"]] = True
+
+        for result in results:
+            if result['target'] in self.reference_results:
+                ref = self.reference_results[result['target']]
+                def compute_diff(column, ratio):
+                    nonlocal result, ref
+                    try:
+                        if ratio:
+                            diff = result[column] / ref[column] - 1.0
+                        else:
+                            diff = result[column] - ref[column]
+                    except TypeError:
+                        diff =None
+                    result['diff_' + column] = diff
+
+                compute_diff('alarms', False)
+                compute_diff('warnings', False)
+                compute_diff('user_time', True)
+                compute_diff('memory', True)
+                compute_diff('coverage', False)
+            else:
+                result['diff_alarms'] = None
+                result['diff_warnings'] = None
+                result['diff_user_time'] = None
+                result['diff_memory'] = None
+                result['diff_coverage'] = None
+
+    def insert_csv(self, result):
+        filename="benchmark-results.csv"
+        file_already_exists=os.path.isfile(filename)
+        fieldnames = [
+            'benchmark_tag', 'timestamp',
+            'analyzer', 'analyzer_hash',
+            'target', 'target_hash',
+            'user_time', 'memory', 'alarms', 'warnings',
+            'sem_reach_fun',  'syn_reach_fun', 'total_fun',
+            'sem_reach_stmt', 'syn_reach_stmt',
+            'cmd_args', 'benchmark_comment']
+        with open(filename, 'a', newline='') as file:
+            writer = csv.DictWriter(file,
+                fieldnames=fieldnames, extrasaction='ignore',
+                delimiter="\t", quotechar='"')
+            if not file_already_exists:
+                writer.writeheader()
+            writer.writerow(result)
+
+    def insert(self, result):
+        completed_result = { **result,
+            'benchmark_tag' : self.benchmark_tag,
+            'benchmark_comment' : self.benchmark_comment,
+            'target_hash' : git_utils.current_rev(result["target"]),
+            'analyzer' : self.analyzer,
+            'analyzer_hash' : self.analyzer_hash }
+        self.insert_csv(completed_result)
+        self.insert_rdb(completed_result)
+
+    def setup_rdb(self):
+        cursor = self.connection.cursor()
+        cursor.execute(
+            "CREATE TABLE IF NOT EXISTS benchmark_results ("
+                "benchmark_tag TEXT NOT NULL,"
+                "timestamp TEXT NOT NULL,"
+                "analyzer TEXT NOT NULL,"
+                "analyzer_hash TEXT NOT NULL,"
+                "target TEXT NOT NULL,"
+                "target_hash TEXT NOT NULL,"
+                "user_time REAL NOT NULL,"
+                "memory INTEGER NOT NULL,"
+                "alarms INTEGER NOT NULL,"
+                "warnings INTEGER NOT NULL,"
+                "sem_reach_fun INTEGER NOT NULL,"
+                "syn_reach_fun INTEGER NOT NULL,"
+                "total_fun INTEGER NOT NULL,"
+                "sem_reach_stmt INTEGER NOT NULL,"
+                "syn_reach_stmt INTEGER NOT NULL,"
+                "cmd_args TEXT NOT NULL,"
+                "benchmark_comment TEXT);")
+        self.connection.commit()
+
+    def insert_rdb(self, result):
+        cursor = self.connection.cursor()
+        cursor.execute(
+            "INSERT INTO benchmark_results("
+                "benchmark_tag, timestamp, "
+                "analyzer, analyzer_hash, target, target_hash, "
+                "user_time, memory, alarms, warnings, "
+                "sem_reach_fun, syn_reach_fun, total_fun, "
+                "sem_reach_stmt, syn_reach_stmt, "
+                "cmd_args, benchmark_comment) "
+            "VALUES("
+                "DATETIME('now','localtime'), "
+                ":benchmark_tag, :analyzer, "
+                ":analyzer_hash, :target, :target_hash, "
+                ":user_time, :memory, :alarms, :warnings, "
+                ":sem_reach_fun, :syn_reach_fun, :total_fun, "
+                ":sem_reach_stmt, :syn_reach_stmt, "
+                ":cmd_args, :benchmark_comment)", result)
+        self.connection.commit()
+
+    def query_rdb(self, analyzer_hash):
+        cursor = self.connection.cursor()
+        cursor.execute(
+            "SELECT "
+                "target, "
+                "avg(user_time) as user_time, avg(memory) as memory, "
+                "min(alarms) as alarms, min(warnings) as warnings, "
+                "max(sem_reach_stmt) as sem_reach_stmt, "
+                "max(syn_reach_stmt) as syn_reach_stmt "
+            "FROM benchmark_results "
+            "WHERE analyzer_hash=? "
+            "GROUP BY target", (analyzer_hash,))
+        results = {}
+        for r in cursor.fetchall():
+            r['coverage'] = r['sem_reach_stmt'] / r['syn_reach_stmt']
+            results[r['target']] = r
+        return results
diff --git a/share/analysis-scripts/clone.sh b/share/analysis-scripts/clone.sh
new file mode 100755
index 00000000000..19fe1a82afc
--- /dev/null
+++ b/share/analysis-scripts/clone.sh
@@ -0,0 +1,91 @@
+#!/bin/bash -eu
+
+git_hash="master"
+clone_dir="frama-c-clones"
+repository_path="git@git.frama-c.com:frama-c/frama-c"
+show_usage=""
+
+while [[ $# > 0 ]]
+do
+    case $1 in
+        -d|--clone-dir)
+            clone_dir="$2"
+            shift
+            ;;
+
+        -p|--repository-path)
+            repository_path="$2"
+            shift
+            ;;
+
+        -h|--help)
+            show_usage="yes"
+            ;;
+
+        *)
+            git_hash="$1"
+            ;;
+    esac
+    shift
+done
+
+if [ -n "$show_usage" ]
+then
+    echo "Usage: $0 HASH"
+    echo "Provides a working tree of Frama-C."
+    echo ""
+    echo "The following arguments can be given:"
+    echo "  -d, --clone-dir             path to the directory where frama-c versions are"
+    echo "                                cloned"
+    echo "  -p, --repository-path PATH  do not clone from frama-c gitlab, use this path instead"
+    echo "  -h, --help                  prints this help and quits"
+    exit 1
+fi
+
+
+bare="$clone_dir/frama-c.git"
+
+# Check if bench clone exists
+if [ ! -d "$bare" ]
+then
+    git clone --bare --quiet $repository_path "$bare"
+    sed --in-place '/bare = true/d' $bare/config
+fi
+
+# Fetch all refs
+git -C $bare fetch origin '+refs/heads/*:refs/heads/*' --prune
+
+# Resolve branch name if given
+git_hash=`git --git-dir="$bare" rev-parse "$git_hash"`
+
+# target_path must be an absolute path
+target_path="$(readlink -f "$clone_dir/$git_hash")"
+
+# Checkout
+if [ ! -e "$target_path" ]
+then
+    # The workdir cmd can extract a working tree of the desired hash
+    # without cloning once more
+    workdir_cmd=`locate git-new-workdir --limit 1`
+    if [ -z "$workdir_cmd" ]
+    then
+        git --git-dir="$bare" worktree add "$target_path" "$git_hash"
+    else
+        bash "$workdir_cmd" "$bare" "$target_path" "$git_hash"
+    fi
+fi
+
+# Build Frama-C
+if [ ! -e "$target_path/build/bin/frama-c" ]
+then
+    (
+        cd "$target_path";
+        autoconf -f --warnings=none;
+        ./configure --quiet --prefix=$(pwd)/build > /dev/null;
+        make -j > /dev/null;
+        make install > /dev/null;
+    )
+fi
+
+# Output repository path
+echo "$target_path"
diff --git a/share/analysis-scripts/find_fun.py b/share/analysis-scripts/find_fun.py
index 8451cdf1302..b8a3565da0b 100755
--- a/share/analysis-scripts/find_fun.py
+++ b/share/analysis-scripts/find_fun.py
@@ -4,7 +4,7 @@
 #                                                                        #
 #  This file is part of Frama-C.                                         #
 #                                                                        #
-#  Copyright (C) 2007-2018                                               #
+#  Copyright (C) 2007-2019                                               #
 #    CEA (Commissariat à l'énergie atomique et aux énergies              #
 #         alternatives)                                                  #
 #                                                                        #
diff --git a/share/analysis-scripts/frama_c_results.py b/share/analysis-scripts/frama_c_results.py
new file mode 100644
index 00000000000..63cdfc18288
--- /dev/null
+++ b/share/analysis-scripts/frama_c_results.py
@@ -0,0 +1,73 @@
+##########################################################################
+#                                                                        #
+#  This file is part of Frama-C.                                         #
+#                                                                        #
+#  Copyright (C) 2007-2019                                               #
+#    CEA (Commissariat à l'énergie atomique et aux énergies              #
+#         alternatives)                                                  #
+#                                                                        #
+#  you can redistribute it and/or modify it under the terms of the GNU   #
+#  Lesser General Public License as published by the Free Software       #
+#  Foundation, version 2.1.                                              #
+#                                                                        #
+#  It is distributed in the hope that it will be useful,                 #
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of        #
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         #
+#  GNU Lesser General Public License for more details.                   #
+#                                                                        #
+#  See the GNU Lesser General Public License version 2.1                 #
+#  for more details (enclosed in the file licenses/LGPLv2.1).            #
+#                                                                        #
+##########################################################################
+
+import re
+
+stat_file_re = re.compile("^([^=]*)=(.*)$", re.MULTILINE)
+
+def load(filename):
+    data = {}
+    try:
+        with open(filename, 'r') as file:
+            content = file.read()
+            for (key,value) in stat_file_re.findall(content):
+                data[key] = value
+    except OSError:
+        pass
+    return data
+
+re_escape_space = re.compile(r'\\ ')
+
+def convert(data, key, to_type, default=None):
+    try:
+        value = data[key].strip()
+        if to_type is str:
+            value = re.sub(r'\\ ', ' ', value)
+            value = re.sub(r'\\,', ',', value)
+            return value
+        else:
+            return to_type(value)
+    except (ValueError, TypeError, KeyError):
+        return default
+
+def parse(data):
+    result = {}
+    result["timestamp"] = convert(data, "timestamp", str)
+    result["sem_reach_fun"] = convert(data, "sem_reach_fun", int)
+    result["syn_reach_fun"] = convert(data, "syn_reach_fun", int)
+    result["total_fun"] = convert(data, "total_fun", int)
+    result["sem_reach_stmt"] = convert(data, "sem_reach_stmt", int)
+    result["syn_reach_stmt"] = convert(data, "syn_reach_stmt", int)
+    result["alarms"] = convert(data, "alarms", int)
+    result["warnings"] = convert(data, "warnings", int)
+    result["user_time"] = convert(data, "user_time", float)
+    result["memory"] = convert(data, "memory", int)
+    result["cmd_args"] = convert(data, "cmd_args", str)
+    result["benchmark_tag"] = convert(data, "benchmark_tag", str)
+    if result["sem_reach_stmt"] != None and result["syn_reach_stmt"] != None:
+        result["coverage"] = result["sem_reach_stmt"] / result["syn_reach_stmt"]
+    else:
+        result["coverage"] = None
+    return result
+
+def read(filename):
+    return parse(load(filename))
diff --git a/share/analysis-scripts/git_utils.py b/share/analysis-scripts/git_utils.py
new file mode 100644
index 00000000000..7e174ebda83
--- /dev/null
+++ b/share/analysis-scripts/git_utils.py
@@ -0,0 +1,59 @@
+##########################################################################
+#                                                                        #
+#  This file is part of Frama-C.                                         #
+#                                                                        #
+#  Copyright (C) 2007-2019                                               #
+#    CEA (Commissariat à l'énergie atomique et aux énergies              #
+#         alternatives)                                                  #
+#                                                                        #
+#  you can redistribute it and/or modify it under the terms of the GNU   #
+#  Lesser General Public License as published by the Free Software       #
+#  Foundation, version 2.1.                                              #
+#                                                                        #
+#  It is distributed in the hope that it will be useful,                 #
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of        #
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         #
+#  GNU Lesser General Public License for more details.                   #
+#                                                                        #
+#  See the GNU Lesser General Public License version 2.1                 #
+#  for more details (enclosed in the file licenses/LGPLv2.1).            #
+#                                                                        #
+##########################################################################
+
+import subprocess
+
+def rev_parse(gitdir, rev):
+    res = subprocess.run(
+        ['git', 'rev-parse', rev],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.DEVNULL,
+        encoding='ascii',
+        cwd=gitdir)
+    name = res.stdout.strip()
+    return name if name else None
+
+def name_rev(gitdir, rev):
+    res = subprocess.run(
+        ['git', 'name-rev', '--name-only', rev],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.DEVNULL,
+        encoding='ascii',
+        cwd=gitdir)
+    name = res.stdout.strip()
+    return name if name else None
+
+def current_rev(gitdir):
+    return name_rev(gitdir, "HEAD")
+
+def is_clean(gitdir):
+    # git diff and diff-index are not working on some of our case studies to
+    # decide whether the workingin dir is clean or not ; git status is more
+    # reliable
+    res = subprocess.run(
+        ['git', 'status', '--untracked-files=no', '--porcelain'],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.DEVNULL,
+        encoding='ascii',
+        cwd=gitdir)
+    return res.returncode == 0 and not res.stdout
+
diff --git a/share/analysis-scripts/list_files.py b/share/analysis-scripts/list_files.py
index 859979b2286..7a1a8972dc6 100755
--- a/share/analysis-scripts/list_files.py
+++ b/share/analysis-scripts/list_files.py
@@ -4,7 +4,7 @@
 #                                                                        #
 #  This file is part of Frama-C.                                         #
 #                                                                        #
-#  Copyright (C) 2007-2018                                               #
+#  Copyright (C) 2007-2019                                               #
 #    CEA (Commissariat à l'énergie atomique et aux énergies              #
 #         alternatives)                                                  #
 #                                                                        #
diff --git a/share/analysis-scripts/results_display.py b/share/analysis-scripts/results_display.py
new file mode 100644
index 00000000000..2f368a3ce0c
--- /dev/null
+++ b/share/analysis-scripts/results_display.py
@@ -0,0 +1,255 @@
+##########################################################################
+#                                                                        #
+#  This file is part of Frama-C.                                         #
+#                                                                        #
+#  Copyright (C) 2007-2019                                               #
+#    CEA (Commissariat à l'énergie atomique et aux énergies              #
+#         alternatives)                                                  #
+#                                                                        #
+#  you can redistribute it and/or modify it under the terms of the GNU   #
+#  Lesser General Public License as published by the Free Software       #
+#  Foundation, version 2.1.                                              #
+#                                                                        #
+#  It is distributed in the hope that it will be useful,                 #
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of        #
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         #
+#  GNU Lesser General Public License for more details.                   #
+#                                                                        #
+#  See the GNU Lesser General Public License version 2.1                 #
+#  for more details (enclosed in the file licenses/LGPLv2.1).            #
+#                                                                        #
+##########################################################################
+
+import re
+import sys
+import string
+import curses
+
+
+sensitivity = 0.02
+
+class ResultsFormatter(string.Formatter):
+    @staticmethod
+    def format_memory(kilobytes):
+        if kilobytes < 4096:
+            return str(kilobytes) + " kiB"
+        megabytes = round(kilobytes / 1024)
+        if megabytes < 4096:
+            return str(megabytes) + " MiB"
+        gigabytes = round(megabytes / 1024)
+        return str(gygabytes) + " GiB"
+
+    @staticmethod
+    def format_time(seconds):
+        if seconds < 10:
+            return str(round(seconds,2)) + "s"
+        if seconds < 100:
+            return str(round(seconds,1)) + "s"
+        if seconds < 600:
+            return str(round(seconds)) + "s"
+        minutes = round(seconds / 60)
+        if minutes < 600:
+            return str(minutes) + "m"
+        hours = round(minutes / 60)
+        return str(hours) + "h"
+
+    @staticmethod
+    def attribute(value, inverted):
+        if value > sensitivity:
+            return "@-" if inverted else "@+"
+        elif value < -sensitivity:
+            return "@+" if inverted else "@-"
+        else:
+            return "@="
+
+    def get_field(self, field_name, args, kwargs):
+        try:
+            return super().get_field(field_name, args, kwargs)
+        except (KeyError, AttributeError):
+            return None,field_name
+
+    def format_field(self, value, format_spec):
+        if value == None:
+            return ""
+        elif format_spec.startswith('+cmp:'):
+            remainder = format_spec.split("+cmp:",1)[1]
+            return (self.attribute(value, False) +
+                self.format_field(value, remainder) + "@=")
+        elif format_spec.startswith('-cmp:'):
+            remainder = format_spec.split("-cmp:",1)[1]
+            return (self.attribute(value, True) +
+                self.format_field(value, remainder) + "@=")
+        elif format_spec == 'time':
+            return self.format_time(value)
+        elif format_spec == 'memory':
+            return self.format_memory(value)
+        else:
+            return super().format_field(value, format_spec)
+
+class UserExitRequest (Exception):
+    pass
+
+class PlainDisplay:
+    NEGATIVE = 1
+    POSITIVE = 2
+    RUNNING = 3
+    HEADER = 4
+
+    columns = [
+        {"size":64, "caption":"Case", "format":"{target_name:s}"},
+        {"size":14, "caption":"Coverage",
+         "format":"{coverage:>8.0%} {diff_coverage:+cmp:+.0%}"},
+        {"size":14, "caption":"Alarms",
+         "format":"{alarms:>8d} {diff_alarms:-cmp:+d}"},
+        {"size":14 , "caption":"Warnings",
+         "format":"{warnings:>8d} {diff_warnings:-cmp:+d}"},
+        {"size":14, "caption":"Time",
+         "format":"{user_time:time} {diff_user_time:-cmp:+.0%}"},
+        {"size":14, "caption":"Memory",
+         "format":"{memory:memory} {diff_memory:-cmp:+.0%}"}]
+
+    def __init__(self):
+        self.NEGATIVE = 0
+        self.POSITIVE = 0
+        self.RUNNING = 0
+        self.HEADER = 0
+        self.OBSOLETE = 0
+        self.needs_update = False
+
+    def write(self, text, attributes=0):
+        sys.stdout.write(text)
+
+    def rich_write(self, text, override=None, size=0):
+        attributes = 0
+        n = 0
+        for s in re.split(r'(@.)', text):
+            if s == "@=":
+                attributes = 0
+            elif s == "@+":
+                attributes = self.POSITIVE
+            elif s == "@-":
+                attributes = self.NEGATIVE
+            else:
+                n += len(s)
+                self.write(s, attributes if override is None else override)
+        if n < size:
+            self.write(' ' * (size - n), attributes if override is None else override)
+
+    fmt = ResultsFormatter()
+
+    def format(self, *args, **kwargs):
+        return self.fmt.format(*args, **kwargs)
+
+    def print_table(self, results):
+        self.write(" ", self.HEADER)
+        for column in self.columns:
+            self.write(self.format('{caption:^{size}}', **column), self.HEADER)
+            self.write(" ", self.HEADER)
+
+        self.write("\n-")
+        for column in self.columns:
+            self.write(self.format('{:-^{size}}', "", **column))
+            self.write("-")
+
+        self.write("\n")
+        for result in results:
+            self.write(" ")
+            for column in self.columns:
+                s = self.fmt.format(column["format"], **result)
+                if result["is_running"]:
+                    attribute = self.RUNNING
+                elif not result["up_to_date"]:
+                    attribute = self.OBSOLETE
+                else:
+                    attribute = None
+                self.rich_write(s, attribute, size=column['size'])
+
+                self.write(" ")
+            self.write("\n")
+
+        self.write("\n")
+        self.needs_update = False
+
+    def process_inputs(self):
+        pass
+
+
+class CursesDisplay(PlainDisplay):
+    def __init__(self, stdscr):
+        self.stdscr = stdscr
+        #curses.mousemask(curses.ALL_MOUSE_EVENTS)
+        stdscr.nodelay(True)
+        stdscr.refresh() # Needs to be done once or nothing will be output
+
+        self.window = curses.newpad(400, 160)
+        curses.init_color(curses.COLOR_YELLOW, 300, 300, 300)
+        curses.init_pair(1, curses.COLOR_RED, 0)
+        curses.init_pair(2, curses.COLOR_GREEN, 0)
+        curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_YELLOW)
+        curses.init_pair(4, curses.COLOR_YELLOW, 0)
+        self.NEGATIVE = curses.color_pair(1)
+        self.POSITIVE = curses.color_pair(2)
+        self.RUNNING = curses.A_BLINK | curses.color_pair(3)
+        self.HEADER = curses.A_BOLD
+        self.OBSOLETE = curses.color_pair(4)
+        self.scroll_y = 0
+
+    def write(self, text, attributes=0):
+        self.window.addstr(text, attributes)
+
+    def print_table(self, results):
+        self.window.clear()
+        PlainDisplay.print_table(self, results)
+        height, width = self.stdscr.getmaxyx()
+        try:
+            self.window.refresh(0, 0, 0, 0, 1, width-1)
+            self.window.refresh(self.scroll_y+2, 0, 2, 0, height-1, width-1)
+        except Exception:
+            # getmaxyx may be out of date, especially when resizing down the
+            # window ; just ignore errors
+            pass
+
+
+    def process_inputs(self):
+        previous_y = self.scroll_y
+        c = self.stdscr.getch()
+        while c != -1:
+            if c == ord('q'):
+                raise UserExitRequest
+            elif c == curses.KEY_UP:
+                self.scroll_y -= 1
+            elif c == curses.KEY_DOWN:
+                self.scroll_y += 1
+            elif c == curses.KEY_NPAGE:
+                self.scroll_y += 10
+            elif c == curses.KEY_PPAGE:
+                self.scroll_y -= 10
+            elif c == curses.KEY_MOUSE:
+                id,x,y,z,bstate = curses.getmouse()
+                if z > 0:
+                    self.scroll_y += 1
+                elif z < 0:
+                    self.scroll_y -= 1
+            self.scroll_y = max(0, self.scroll_y)
+            c = self.stdscr.getch()
+        if self.scroll_y != previous_y:
+            self.needs_update = True
+
+
+def wrapper(f, *args, **kwargs):
+    if 'curses' in kwargs:
+        use_curses = kwargs['curses']
+        del kwargs['curses']
+    else:
+        use_curses = False
+
+    if use_curses:
+        def g(stdscr):
+            nonlocal f, args, kwargs
+            display = CursesDisplay(stdscr)
+            return f(display, *args, **kwargs)
+        return curses.wrapper(g)
+    else:
+        display = PlainDisplay()
+        return f(display, *args, **kwargs)
+
diff --git a/share/analysis-scripts/summary.py b/share/analysis-scripts/summary.py
new file mode 100755
index 00000000000..9c0c586ef51
--- /dev/null
+++ b/share/analysis-scripts/summary.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+#-*- coding: utf-8 -*-
+##########################################################################
+#                                                                        #
+#  This file is part of Frama-C.                                         #
+#                                                                        #
+#  Copyright (C) 2007-2019                                               #
+#    CEA (Commissariat à l'énergie atomique et aux énergies              #
+#         alternatives)                                                  #
+#                                                                        #
+#  you can redistribute it and/or modify it under the terms of the GNU   #
+#  Lesser General Public License as published by the Free Software       #
+#  Foundation, version 2.1.                                              #
+#                                                                        #
+#  It is distributed in the hope that it will be useful,                 #
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of        #
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         #
+#  GNU Lesser General Public License for more details.                   #
+#                                                                        #
+#  See the GNU Lesser General Public License version 2.1                 #
+#  for more details (enclosed in the file licenses/LGPLv2.1).            #
+#                                                                        #
+##########################################################################
+
+import re
+import sys
+import subprocess
+import time
+import os
+import signal
+import argparse
+import uuid
+
+import frama_c_results
+import results_display
+import benchmark_database
+
+class OperationException(Exception):
+    pass
+
+def build_env(framac):
+    if framac is None:
+        return { **os.environ }
+    else:
+        bindir = framac + '/build/bin'
+        return { **os.environ,  'PATH' : bindir + ':' + os.environ['PATH'] }
+
+def list_targets():
+    env = build_env(framac)
+    res = subprocess.run(
+        ["make", "--quiet", "display-targets"],
+        env=env,
+        stdout=subprocess.PIPE,
+        encoding='ascii')
+    return res.stdout.split()
+
+def clone_frama_c(clonedir, hash):
+    print("Cloning Frama-C", hash, "...")
+    res = subprocess.run(
+        ["./scripts/clone.sh", "--clone-dir", clonedir, hash],
+        stdout=subprocess.PIPE,
+        encoding='ascii')
+    if res.returncode != 0:
+        raise OperationException("Cannot clone repository. Try to manually"
+            "remove the broken clone in " + clonedir)
+    return res.stdout.strip()
+
+def run_make(framac, benchmark_tag=None):
+    args = ['make', '--keep-going', 'all']
+    env = build_env(framac)
+    if not framac is None:
+        bindir = framac + '/build/bin'
+        args += [
+            'FRAMAC_DIR=' + bindir,
+            'FRAMAC=' + bindir + '/frama-c',
+            'FRAMAC_CONFIG=' + bindir + '/frama-c-config']
+    if benchmark_tag is None:
+        args += ['-j', '8']
+    else:
+        args += ['BENCHMARK=' + benchmark_tag]
+    return subprocess.Popen(args, env=env,
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.PIPE,
+        preexec_fn=os.setsid)
+
+def terminate_process(process):
+    if process is None:
+        return b""
+    else:
+        try:
+            os.killpg(os.getpgid(process.pid), signal.SIGTERM)
+            pass
+        except ProcessLookupError:
+            pass
+        output,errors = process.communicate()
+        return errors
+
+def smart_rename(target):
+    target = re.sub('main\.eva$', '', target)
+    target = re.sub('\.eva$', '', target)
+    target = re.sub('qds/frama-c', 'qds', target)
+    return target
+
+def is_running(target):
+    return os.path.isfile(target + '/running')
+
+def poll_results(targets, benchmark_tag):
+    results = []
+    for target in targets:
+        filename = target + '/stats.txt'
+        result = frama_c_results.read(filename)
+        result["target"] = target
+        result["target_name"] = smart_rename(target)
+        result["is_running"] = is_running(target)
+        result["up_to_date"] = benchmark_tag is None or benchmark_tag == result['benchmark_tag']
+        results.append(result);
+    return results
+
+
+def run_analyses(display, database, framac, benchmark_tag):
+    results = []
+    targets = list_targets()
+    process = run_make(framac, benchmark_tag)
+    errors = b""
+    next_poll = time.time()
+
+    def update():
+        nonlocal  display, database, targets, benchmark_tag, results
+        results = poll_results(targets, benchmark_tag)
+        if not database is None:
+            database.update(results)
+        display.needs_update = True
+
+    try:
+        while process.poll() is None:
+            if time.time() >= next_poll:
+                update()
+                next_poll = time.time() + 2.0
+            display.process_inputs()
+            if display.needs_update:
+                display.print_table(results)
+            time.sleep(0.05)
+        update()
+    except (KeyboardInterrupt, results_display.UserExitRequest):
+        print("Analyzes interrupted by user.")
+    except Exception as e:
+        # terminate_process below is somehow blocking the exception printing
+        errors += bytearray(str(e), 'ascii')
+        raise e
+    finally:
+        errors += terminate_process(process)
+    return results,errors
+
+
+parser = argparse.ArgumentParser(
+    description="Run analyses and summarize the results. Must be run in a "
+                "directory with a Makefile having two rules: 'all', a target "
+                "that runs the analysis, and 'display-targets', the target that "
+                "lists the built results.")
+parser.add_argument('rev', nargs='?', metavar="REVISION",
+    help="a Frama-C revision to use for analyses (default: use the "
+        "default configuration for Frama-C)")
+parser.add_argument('-b', '--benchmark',
+    action="store_true",
+    help="sets benchmark mode: do not run analyses in parallel and rerun all "
+        "analyses")
+parser.add_argument('-v', '--vs',
+    action="store", metavar="REVISION", default="master",
+    help="a revision to compare the results to")
+parser.add_argument('-c', '--comment',
+    action="store", metavar="COMMENT",
+    help="when benchmarking, add this comment inside the database")
+parser.add_argument('-p', '--repository-path',
+    action="store", metavar="PATH",
+    help="don't clone Frama-C, use this git repository instead")
+
+
+errors = b''
+
+try:
+    args = parser.parse_args()
+
+    if args.repository_path is None:
+        if args.rev is None:
+            gitdir = None
+            framac = None
+        else:
+            clonedir = "./frama-c-clones"
+            gitdir = clonedir + "/frama-c.git"
+            framac = clone_frama_c(clonedir, args.rev)
+    else:
+        framac = args.repository_path
+        gitdir = framac
+
+    if args.benchmark:
+        benchmark_tag=str(uuid.uuid1())
+        print("Running benchmarks with benchmark tag", benchmark_tag, "...")
+    else:
+        benchmark_tag=None
+        print("Running analyses ...")
+
+    benchmark_comment = args.comment
+
+    if gitdir is None:
+        database = None
+    else:
+        database = benchmark_database.Database(benchmark_tag, benchmark_comment,
+            gitdir, args.rev, args.vs)
+
+    results,errors = results_display.wrapper(run_analyses, database, framac,
+        benchmark_tag, curses=True)
+
+    print("Results:\n")
+    results_display.PlainDisplay().print_table(results)
+
+except OperationException as e:
+    errors += bytearray(str(e), 'ascii')
+
+sys.stderr.buffer.write(errors + b'\n')
-- 
GitLab