unit-test: Introduce explicit build system concepts

Separate the mechanics of configuring, building and testing repositories
into build-system-specific classes that have explicit configure, build,
install, test and analyze phases.

I'm sure we'll achieve parity with bitbake shortly.

Tested:

Ran the CI scripts against the following repositories and confirmed they
are built and analyzed as expected:

* libmctp: Supports both autotools and CMake. The patch prefers
  autotools
* hiomapd: Autotools-based
* bmcweb: CMake-based
* pldm: Meson-based
* phosphor-dbus-monitor: autotools-based, but has a custom dependency on
  phosphor-snmp

Also introduced breaks in the libmctp packaging for each of the
configure, build and test phases to confirm that errors resulted in
exceptions terminating the run.

Change-Id: Ib02d5337e01068291e8bf6f87d153d9bb2d59024
Signed-off-by: Andrew Jeffery <andrew@aj.id.au>
diff --git a/scripts/unit-test.py b/scripts/unit-test.py
index 1572c75..c6dc14c 100755
--- a/scripts/unit-test.py
+++ b/scripts/unit-test.py
@@ -255,78 +255,22 @@
     return repo_inst
 
 
-def get_autoconf_deps(pkgdir):
+def make_target_exists(target):
     """
-    Parse the given 'configure.ac' file for package dependencies and return
-    a list of the dependencies found. If the package is not autoconf it is just
-    ignored.
+    Runs a check against the makefile in the current directory to determine
+    if the target exists so that it can be built.
 
     Parameter descriptions:
-    pkgdir              Directory where package source is located
+    target              The make target we are checking
     """
-    configure_ac = os.path.join(pkgdir, 'configure.ac')
-    if not os.path.exists(configure_ac):
-        return []
+    try:
+        cmd = [ 'make', '-n', target ]
+        with open(os.devnull, 'w') as devnull:
+            check_call(cmd, stdout=devnull, stderr=devnull)
+        return True
+    except CalledProcessError:
+        return False
 
-    configure_ac_contents = ''
-    # Prepend some special function overrides so we can parse out dependencies
-    for macro in DEPENDENCIES.iterkeys():
-        configure_ac_contents += ('m4_define([' + macro + '], [' +
-                macro + '_START$' + str(DEPENDENCIES_OFFSET[macro] + 1) +
-                macro + '_END])\n')
-    with open(configure_ac, "rt") as f:
-        configure_ac_contents += f.read()
-
-    autoconf_process = subprocess.Popen(['autoconf', '-Wno-undefined', '-'],
-            stdin=subprocess.PIPE, stdout=subprocess.PIPE,
-            stderr=subprocess.PIPE)
-    (stdout, stderr) = autoconf_process.communicate(input=configure_ac_contents)
-    if not stdout:
-        print(stderr)
-        raise Exception("Failed to run autoconf for parsing dependencies")
-
-    # Parse out all of the dependency text
-    matches = []
-    for macro in DEPENDENCIES.iterkeys():
-        pattern = '(' + macro + ')_START(.*?)' + macro + '_END'
-        for match in re.compile(pattern).finditer(stdout):
-            matches.append((match.group(1), match.group(2)))
-
-    # Look up dependencies from the text
-    found_deps = []
-    for macro, deptext in matches:
-        for potential_dep in deptext.split(' '):
-            for known_dep in DEPENDENCIES[macro].iterkeys():
-                if potential_dep.startswith(known_dep):
-                    found_deps.append(DEPENDENCIES[macro][known_dep])
-
-    return found_deps
-
-def get_meson_deps(pkgdir):
-    """
-    Parse the given 'meson.build' file for package dependencies and return
-    a list of the dependencies found. If the package is not meson compatible
-    it is just ignored.
-
-    Parameter descriptions:
-    pkgdir              Directory where package source is located
-    """
-    meson_build = os.path.join(pkgdir, 'meson.build')
-    if not os.path.exists(meson_build):
-        return []
-
-    found_deps = []
-    for root, dirs, files in os.walk(pkgdir):
-        if 'meson.build' not in files:
-            continue
-        with open(os.path.join(root, 'meson.build'), 'rt') as f:
-            build_contents = f.read()
-        for match in re.finditer(r"dependency\('([^']*)'.*?\)\n", build_contents):
-            maybe_dep = DEPENDENCIES['PKG_CHECK_MODULES'].get(match.group(1))
-            if maybe_dep is not None:
-                found_deps.append(maybe_dep)
-
-    return found_deps
 
 make_parallel = [
     'make',
@@ -338,51 +282,6 @@
     '-O',
 ]
 
-def enFlag(flag, enabled):
-    """
-    Returns an configure flag as a string
-
-    Parameters:
-    flag                The name of the flag
-    enabled             Whether the flag is enabled or disabled
-    """
-    return '--' + ('enable' if enabled else 'disable') + '-' + flag
-
-def mesonFeature(val):
-    """
-    Returns the meson flag which signifies the value
-
-    True is enabled which requires the feature.
-    False is disabled which disables the feature.
-    None is auto which autodetects the feature.
-
-    Parameters:
-    val                 The value being converted
-    """
-    if val is True:
-        return "enabled"
-    elif val is False:
-        return "disabled"
-    elif val is None:
-        return "auto"
-    else:
-        raise Exception("Bad meson feature value")
-
-def parse_meson_options(options_file):
-    """
-    Returns a set of options defined in the provides meson_options.txt file
-
-    Parameters:
-    options_file        The file containing options
-    """
-    options_contents = ''
-    with open(options_file, "rt") as f:
-        options_contents += f.read()
-    options = sets.Set()
-    pattern = 'option\\(\\s*\'([^\']*)\''
-    for match in re.compile(pattern).finditer(options_contents):
-        options.add(match.group(1))
-    return options
 
 def build_and_install(name, build_for_testing=False):
     """
@@ -398,63 +297,18 @@
     # Refresh dynamic linker run time bindings for dependencies
     check_call_cmd('sudo', '-n', '--', 'ldconfig')
 
-    # Build & install this package
-    # Always try using meson first
-    if os.path.exists('meson.build'):
-        meson_options = sets.Set()
-        if os.path.exists("meson_options.txt"):
-            meson_options = parse_meson_options("meson_options.txt")
-        meson_flags = [
-            '-Db_colorout=never',
-            '-Dwerror=true',
-            '-Dwarning_level=3',
-        ]
-        if build_for_testing:
-            meson_flags.append('--buildtype=debug')
-        else:
-            meson_flags.append('--buildtype=debugoptimized')
-        if 'tests' in meson_options:
-            meson_flags.append('-Dtests=' + mesonFeature(build_for_testing))
-        if 'examples' in meson_options:
-            meson_flags.append('-Dexamples=' + str(build_for_testing).lower())
-        if MESON_FLAGS.get(name) is not None:
-            meson_flags.extend(MESON_FLAGS.get(name))
-        try:
-            check_call_cmd('meson', 'setup', '--reconfigure', 'build', *meson_flags)
-        except:
-            shutil.rmtree('build')
-            check_call_cmd('meson', 'setup', 'build', *meson_flags)
-        check_call_cmd('ninja', '-C', 'build')
-        check_call_cmd('sudo', '-n', '--', 'ninja', '-C', 'build', 'install')
-    # Assume we are autoconf otherwise
+    pkg = Package()
+    if build_for_testing:
+        pkg.test()
     else:
-        conf_flags = [
-            enFlag('silent-rules', False),
-            enFlag('examples', build_for_testing),
-            enFlag('tests', build_for_testing),
-        ]
-        if not TEST_ONLY:
-            conf_flags.extend([
-                enFlag('code-coverage', build_for_testing),
-                enFlag('valgrind', build_for_testing),
-            ])
-        # Add any necessary configure flags for package
-        if CONFIGURE_FLAGS.get(name) is not None:
-            conf_flags.extend(CONFIGURE_FLAGS.get(name))
-        for bootstrap in ['bootstrap.sh', 'bootstrap', 'autogen.sh']:
-            if os.path.exists(bootstrap):
-                check_call_cmd('./' + bootstrap)
-                break
-        check_call_cmd('./configure', *conf_flags)
-        check_call_cmd(*make_parallel)
-        check_call_cmd('sudo', '-n', '--', *(make_parallel + [ 'install' ]))
+        pkg.install()
+
 
 def build_dep_tree(name, pkgdir, dep_added, head, branch, dep_tree=None):
     """
     For each package (name), starting with the package to be unit tested,
-    parse its 'configure.ac' file from within the package's directory(pkgdir)
-    for each package dependency defined recursively doing the same thing
-    on each package found as a dependency.
+    extract its dependencies. For each package dependency defined, recursively
+    apply the same strategy
 
     Parameter descriptions:
     name                Name of the package
@@ -471,11 +325,9 @@
         cache = depcache.readline()
 
     # Read out pkg dependencies
-    pkg_deps = []
-    pkg_deps += get_autoconf_deps(pkgdir)
-    pkg_deps += get_meson_deps(pkgdir)
+    pkg = Package(name, pkgdir)
 
-    for dep in sets.Set(pkg_deps):
+    for dep in sets.Set(pkg.build_system().dependencies()):
         if dep in cache:
             continue
         # Dependency package not already known
@@ -506,36 +358,6 @@
 
     return dep_added
 
-def make_target_exists(target):
-    """
-    Runs a check against the makefile in the current directory to determine
-    if the target exists so that it can be built.
-
-    Parameter descriptions:
-    target              The make target we are checking
-    """
-    try:
-        cmd = [ 'make', '-n', target ]
-        with open(os.devnull, 'w') as devnull:
-            check_call(cmd, stdout=devnull, stderr=devnull)
-        return True
-    except CalledProcessError:
-        return False
-
-def run_unit_tests():
-    """
-    Runs the unit tests for the package via `make check`
-    """
-    try:
-        cmd = make_parallel + [ 'check' ]
-        for i in range(0, args.repeat):
-            check_call_cmd(*cmd)
-    except CalledProcessError:
-        for root, _, files in os.walk(os.getcwd()):
-            if 'test-suite.log' not in files:
-                continue
-            check_call_cmd('cat', os.path.join(root, 'test-suite.log'))
-        raise Exception('Unit tests failed')
 
 def run_cppcheck():
     match_re = re.compile('((?!\.mako\.).)*\.[ch](?:pp)?$', re.I)
@@ -625,58 +447,6 @@
         os.remove(src)
         os.remove(exe)
 
-def meson_setup_exists(setup):
-    """
-    Returns whether the meson build supports the named test setup.
-
-    Parameter descriptions:
-    setup              The setup target to check
-    """
-    try:
-        with open(os.devnull, 'w') as devnull:
-            output = subprocess.check_output(
-                    ['meson', 'test', '-C', 'build',
-                     '--setup', setup, '-t', '0'],
-                    stderr=subprocess.STDOUT)
-    except CalledProcessError as e:
-        output = e.output
-    return not re.search('Test setup .* not found from project', output)
-
-def run_unit_tests_meson():
-    """
-    Runs the unit tests for the meson based package
-    """
-    try:
-        check_call_cmd('meson', 'test', '-C', 'build')
-    except CalledProcessError:
-        for root, _, files in os.walk(os.getcwd()):
-            if 'testlog.txt' not in files:
-                continue
-            check_call_cmd('cat', os.path.join(root, 'testlog.txt'))
-        raise Exception('Unit tests failed')
-
-def maybe_meson_valgrind():
-    """
-    Potentially runs the unit tests through valgrind for the package
-    via `meson test`. The package can specify custom valgrind configurations
-    by utilizing add_test_setup() in a meson.build
-    """
-    if not is_valgrind_safe():
-        sys.stderr.write("###### Skipping valgrind ######\n")
-        return
-    try:
-        if meson_setup_exists('valgrind'):
-            check_call_cmd('meson', 'test', '-C', 'build',
-                           '--setup', 'valgrind')
-        else:
-            check_call_cmd('meson', 'test', '-C', 'build',
-                           '--wrapper', 'valgrind')
-    except CalledProcessError:
-        for root, _, files in os.walk(os.getcwd()):
-            if 'testlog-valgrind.txt' not in files:
-                continue
-            check_call_cmd('cat', os.path.join(root, 'testlog-valgrind.txt'))
-        raise Exception('Valgrind tests failed')
 
 def maybe_make_valgrind():
     """
@@ -721,6 +491,468 @@
     except CalledProcessError:
         raise Exception('Code coverage failed')
 
+
+class BuildSystem(object):
+    """
+    Build systems generally provide the means to configure, build, install and
+    test software. The BuildSystem class defines a set of interfaces on top of
+    which Autotools, Meson, CMake and possibly other build system drivers can
+    be implemented, separating out the phases to control whether a package
+    should merely be installed or also tested and analyzed.
+    """
+    def __init__(self, package, path):
+        """Initialise the driver with properties independent of the build system
+
+        Keyword arguments:
+        package: The name of the package. Derived from the path if None
+        path: The path to the package. Set to the working directory if None
+        """
+        self.path = "." if not path else path
+        self.package = package if package else os.path.basename(os.path.realpath(self.path))
+        self.build_for_testing=False
+
+    def probe(self):
+        """Test if the build system driver can be applied to the package
+
+        Return True if the driver can drive the package's build system,
+        otherwise False.
+
+        Generally probe() is implemented by testing for the presence of the
+        build system's configuration file(s).
+        """
+        raise NotImplemented
+
+    def dependencies(self):
+        """Provide the package's dependencies
+
+        Returns a list of dependencies. If no dependencies are required then an
+        empty list must be returned.
+
+        Generally dependencies() is implemented by analysing and extracting the
+        data from the build system configuration.
+        """
+        raise NotImplemented
+
+    def configure(self, build_for_testing):
+        """Configure the source ready for building
+
+        Should raise an exception if configuration failed.
+
+        Keyword arguments:
+        build_for_testing: Mark the package as being built for testing rather
+                           than for installation as a dependency for the
+                           package under test. Setting to True generally
+                           implies that the package will be configured to build
+                           with debug information, at a low level of
+                           optimisation and possibly with sanitizers enabled.
+
+        Generally configure() is implemented by invoking the build system
+        tooling to generate Makefiles or equivalent.
+        """
+        raise NotImplemented
+
+    def build(self):
+        """Build the software ready for installation and/or testing
+
+        Should raise an exception if the build fails
+
+        Generally build() is implemented by invoking `make` or `ninja`.
+        """
+        raise NotImplemented
+
+    def install(self):
+        """Install the software ready for use
+
+        Should raise an exception if installation fails
+
+        Like build(), install() is generally implemented by invoking `make` or
+        `ninja`.
+        """
+        raise NotImplemented
+
+    def test(self):
+        """Build and run the test suite associated with the package
+
+        Should raise an exception if the build or testing fails.
+
+        Like install(), test() is generally implemented by invoking `make` or
+        `ninja`.
+        """
+        raise NotImplemented
+
+    def analyze(self):
+        """Run any supported analysis tools over the codebase
+
+        Should raise an exception if analysis fails.
+
+        Some analysis tools such as scan-build need injection into the build
+        system. analyze() provides the necessary hook to implement such
+        behaviour. Analyzers independent of the build system can also be
+        specified here but at the cost of possible duplication of code between
+        the build system driver implementations.
+        """
+        raise NotImplemented
+
+
+class Autotools(BuildSystem):
+    def __init__(self, package=None, path=None):
+        super(Autotools, self).__init__(package, path)
+
+    def probe(self):
+        return os.path.isfile(os.path.join(self.path, 'configure.ac'))
+
+    def dependencies(self):
+        configure_ac = os.path.join(self.path, 'configure.ac')
+
+        configure_ac_contents = ''
+        # Prepend some special function overrides so we can parse out dependencies
+        for macro in DEPENDENCIES.iterkeys():
+            configure_ac_contents += ('m4_define([' + macro + '], [' +
+                    macro + '_START$' + str(DEPENDENCIES_OFFSET[macro] + 1) +
+                    macro + '_END])\n')
+        with open(configure_ac, "rt") as f:
+            configure_ac_contents += f.read()
+
+        autoconf_process = subprocess.Popen(['autoconf', '-Wno-undefined', '-'],
+                stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE)
+        (stdout, stderr) = autoconf_process.communicate(input=configure_ac_contents)
+        if not stdout:
+            print(stderr)
+            raise Exception("Failed to run autoconf for parsing dependencies")
+
+        # Parse out all of the dependency text
+        matches = []
+        for macro in DEPENDENCIES.iterkeys():
+            pattern = '(' + macro + ')_START(.*?)' + macro + '_END'
+            for match in re.compile(pattern).finditer(stdout):
+                matches.append((match.group(1), match.group(2)))
+
+        # Look up dependencies from the text
+        found_deps = []
+        for macro, deptext in matches:
+            for potential_dep in deptext.split(' '):
+                for known_dep in DEPENDENCIES[macro].iterkeys():
+                    if potential_dep.startswith(known_dep):
+                        found_deps.append(DEPENDENCIES[macro][known_dep])
+
+        return found_deps
+
+    def _configure_feature(self, flag, enabled):
+        """
+        Returns an configure flag as a string
+
+        Parameters:
+        flag                The name of the flag
+        enabled             Whether the flag is enabled or disabled
+        """
+        return '--' + ('enable' if enabled else 'disable') + '-' + flag
+
+    def configure(self, build_for_testing):
+        self.build_for_testing = build_for_testing
+        conf_flags = [
+            self._configure_feature('silent-rules', False),
+            self._configure_feature('examples', build_for_testing),
+            self._configure_feature('tests', build_for_testing),
+        ]
+        if not TEST_ONLY:
+            conf_flags.extend([
+                self._configure_feature('code-coverage', build_for_testing),
+                self._configure_feature('valgrind', build_for_testing),
+            ])
+        # Add any necessary configure flags for package
+        if CONFIGURE_FLAGS.get(self.package) is not None:
+            conf_flags.extend(CONFIGURE_FLAGS.get(self.package))
+        for bootstrap in ['bootstrap.sh', 'bootstrap', 'autogen.sh']:
+            if os.path.exists(bootstrap):
+                check_call_cmd('./' + bootstrap)
+                break
+        check_call_cmd('./configure', *conf_flags)
+
+    def build(self):
+        check_call_cmd(*make_parallel)
+
+    def install(self):
+        check_call_cmd('sudo', '-n', '--', *(make_parallel + [ 'install' ]))
+
+    def test(self):
+        try:
+            cmd = make_parallel + [ 'check' ]
+            for i in range(0, args.repeat):
+                check_call_cmd(*cmd)
+        except CalledProcessError:
+            for root, _, files in os.walk(os.getcwd()):
+                if 'test-suite.log' not in files:
+                    continue
+                check_call_cmd('cat', os.path.join(root, 'test-suite.log'))
+            raise Exception('Unit tests failed')
+
+    def analyze(self):
+        maybe_make_valgrind()
+        maybe_make_coverage()
+        run_cppcheck()
+
+
+class CMake(BuildSystem):
+    def __init__(self, package=None, path=None):
+        super(CMake, self).__init__(package, path)
+
+    def probe(self):
+        return os.path.isfile(os.path.join(self.path, 'CMakeLists.txt'))
+
+    def dependencies(self):
+        return []
+
+    def configure(self, build_for_testing):
+        self.build_for_testing = build_for_testing
+        check_call_cmd('cmake', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON', '.')
+
+    def build(self):
+        check_call_cmd('cmake', '--build', '.', '--', '-j',
+                       str(multiprocessing.cpu_count()))
+
+    def install(self):
+        pass
+
+    def test(self):
+        if make_target_exists('test'):
+            check_call_cmd('ctest', '.')
+
+    def analyze(self):
+        if os.path.isfile('.clang-tidy'):
+            check_call_cmd('run-clang-tidy-8.py', '-p', '.')
+        maybe_make_valgrind()
+        maybe_make_coverage()
+        run_cppcheck()
+
+
+class Meson(BuildSystem):
+    def __init__(self, package=None, path=None):
+        super(Meson, self).__init__(package, path)
+
+    def probe(self):
+        return os.path.isfile(os.path.join(self.path, 'meson.build'))
+
+    def dependencies(self):
+        meson_build = os.path.join(self.path, 'meson.build')
+        if not os.path.exists(meson_build):
+            return []
+
+        found_deps = []
+        for root, dirs, files in os.walk(self.path):
+            if 'meson.build' not in files:
+                continue
+            with open(os.path.join(root, 'meson.build'), 'rt') as f:
+                build_contents = f.read()
+            for match in re.finditer(r"dependency\('([^']*)'.*?\)\n", build_contents):
+                maybe_dep = DEPENDENCIES['PKG_CHECK_MODULES'].get(match.group(1))
+                if maybe_dep is not None:
+                    found_deps.append(maybe_dep)
+
+        return found_deps
+
+    def _parse_options(self, options_file):
+        """
+        Returns a set of options defined in the provides meson_options.txt file
+
+        Parameters:
+        options_file        The file containing options
+        """
+        options_contents = ''
+        with open(options_file, "rt") as f:
+            options_contents += f.read()
+        options = sets.Set()
+        pattern = 'option\\(\\s*\'([^\']*)\''
+        for match in re.compile(pattern).finditer(options_contents):
+            options.add(match.group(1))
+        return options
+
+    def _configure_feature(self, val):
+        """
+        Returns the meson flag which signifies the value
+
+        True is enabled which requires the feature.
+        False is disabled which disables the feature.
+        None is auto which autodetects the feature.
+
+        Parameters:
+        val                 The value being converted
+        """
+        if val is True:
+            return "enabled"
+        elif val is False:
+            return "disabled"
+        elif val is None:
+            return "auto"
+        else:
+            raise Exception("Bad meson feature value")
+
+    def configure(self, build_for_testing):
+        self.build_for_testing = build_for_testing
+        meson_options = sets.Set()
+        if os.path.exists("meson_options.txt"):
+            meson_options = self._parse_options("meson_options.txt")
+        meson_flags = [
+            '-Db_colorout=never',
+            '-Dwerror=true',
+            '-Dwarning_level=3',
+        ]
+        if build_for_testing:
+            meson_flags.append('--buildtype=debug')
+        else:
+            meson_flags.append('--buildtype=debugoptimized')
+        if 'tests' in meson_options:
+            meson_flags.append('-Dtests=' + self._configure_feature(build_for_testing))
+        if 'examples' in meson_options:
+            meson_flags.append('-Dexamples=' + str(build_for_testing).lower())
+        if MESON_FLAGS.get(self.package) is not None:
+            meson_flags.extend(MESON_FLAGS.get(self.package))
+        try:
+            check_call_cmd('meson', 'setup', '--reconfigure', 'build', *meson_flags)
+        except:
+            shutil.rmtree('build')
+            check_call_cmd('meson', 'setup', 'build', *meson_flags)
+
+    def build(self):
+        check_call_cmd('ninja', '-C', 'build')
+
+    def install(self):
+        check_call_cmd('sudo', '-n', '--', 'ninja', '-C', 'build', 'install')
+
+    def test(self):
+        try:
+            check_call_cmd('meson', 'test', '-C', 'build')
+        except CalledProcessError:
+            for root, _, files in os.walk(os.getcwd()):
+                if 'testlog.txt' not in files:
+                    continue
+                check_call_cmd('cat', os.path.join(root, 'testlog.txt'))
+            raise Exception('Unit tests failed')
+
+    def _setup_exists(self, setup):
+        """
+        Returns whether the meson build supports the named test setup.
+
+        Parameter descriptions:
+        setup              The setup target to check
+        """
+        try:
+            with open(os.devnull, 'w') as devnull:
+                output = subprocess.check_output(
+                        ['meson', 'test', '-C', 'build',
+                         '--setup', setup, '-t', '0'],
+                        stderr=subprocess.STDOUT)
+        except CalledProcessError as e:
+            output = e.output
+        return not re.search('Test setup .* not found from project', output)
+
+    def _maybe_valgrind(self):
+        """
+        Potentially runs the unit tests through valgrind for the package
+        via `meson test`. The package can specify custom valgrind configurations
+        by utilizing add_test_setup() in a meson.build
+        """
+        if not is_valgrind_safe():
+            sys.stderr.write("###### Skipping valgrind ######\n")
+            return
+        try:
+            if self._setup_exists('valgrind'):
+                check_call_cmd('meson', 'test', '-C', 'build',
+                               '--setup', 'valgrind')
+            else:
+                check_call_cmd('meson', 'test', '-C', 'build',
+                               '--wrapper', 'valgrind')
+        except CalledProcessError:
+            for root, _, files in os.walk(os.getcwd()):
+                if 'testlog-valgrind.txt' not in files:
+                    continue
+                check_call_cmd('cat', os.path.join(root, 'testlog-valgrind.txt'))
+            raise Exception('Valgrind tests failed')
+
+    def analyze(self):
+        self._maybe_valgrind()
+
+        # Run clang-tidy only if the project has a configuration
+        if os.path.isfile('.clang-tidy'):
+            check_call_cmd('run-clang-tidy-8.py', '-p',
+                           'build')
+        # Run the basic clang static analyzer otherwise
+        else:
+            check_call_cmd('ninja', '-C', 'build',
+                           'scan-build')
+
+        # Run tests through sanitizers
+        # b_lundef is needed if clang++ is CXX since it resolves the
+        # asan symbols at runtime only. We don't want to set it earlier
+        # in the build process to ensure we don't have undefined
+        # runtime code.
+        if is_sanitize_safe():
+            check_call_cmd('meson', 'configure', 'build',
+                           '-Db_sanitize=address,undefined',
+                           '-Db_lundef=false')
+            check_call_cmd('meson', 'test', '-C', 'build',
+                           '--logbase', 'testlog-ubasan')
+            # TODO: Fix memory sanitizer
+            #check_call_cmd('meson', 'configure', 'build',
+            #               '-Db_sanitize=memory')
+            #check_call_cmd('meson', 'test', '-C', 'build'
+            #               '--logbase', 'testlog-msan')
+            check_call_cmd('meson', 'configure', 'build',
+                           '-Db_sanitize=none', '-Db_lundef=true')
+        else:
+            sys.stderr.write("###### Skipping sanitizers ######\n")
+
+        # Run coverage checks
+        check_call_cmd('meson', 'configure', 'build',
+                       '-Db_coverage=true')
+        self.test()
+        # Only build coverage HTML if coverage files were produced
+        for root, dirs, files in os.walk('build'):
+            if any([f.endswith('.gcda') for f in files]):
+                check_call_cmd('ninja', '-C', 'build',
+                               'coverage-html')
+                break
+        check_call_cmd('meson', 'configure', 'build',
+                       '-Db_coverage=false')
+        run_cppcheck()
+
+
+class Package(object):
+    def __init__(self, name=None, path=None):
+        self.supported = [ Autotools, Meson, CMake ]
+        self.name = name
+        self.path = path
+        self.test_only = False
+
+    def build_systems(self):
+        instances = ( system(self.name, self.path) for system in self.supported )
+        return ( instance for instance in instances if instance.probe() )
+
+    def build_system(self, preferred=None):
+        systems = self.build_systems()
+
+        if preferred:
+            return { type(system): system for system in systems }[preferred]
+
+        return next(iter(systems))
+
+    def install(self, system=None):
+        if not system:
+            system = self.build_system()
+
+        system.configure(False)
+        system.build()
+        system.install()
+
+    def test(self):
+        system = self.build_system()
+        system.configure(True)
+        system.build()
+        system.install()
+        system.test()
+        system.analyze()
+
+
 def find_file(filename, basedir):
     """
     Finds all occurrences of a file in the base directory
@@ -832,112 +1064,39 @@
     if FORMAT_CODE:
         check_call_cmd("./format-code.sh", CODE_SCAN_DIR)
 
-    # Automake and meson
-    if (os.path.isfile(CODE_SCAN_DIR + "/configure.ac") or
-        os.path.isfile(CODE_SCAN_DIR + '/meson.build')):
-        prev_umask = os.umask(000)
-        # Determine dependencies and add them
-        dep_added = dict()
-        dep_added[UNIT_TEST_PKG] = False
-        # Create dependency tree
-        dep_tree = DepTree(UNIT_TEST_PKG)
-        build_dep_tree(UNIT_TEST_PKG,
-                       os.path.join(WORKSPACE, UNIT_TEST_PKG),
-                       dep_added,
-                       dep_tree,
-                       BRANCH)
+    prev_umask = os.umask(000)
 
-        # Reorder Dependency Tree
-        for pkg_name, regex_str in DEPENDENCIES_REGEX.iteritems():
-            dep_tree.ReorderDeps(pkg_name, regex_str)
-        if args.verbose:
-            dep_tree.PrintTree()
-        install_list = dep_tree.GetInstallList()
-        # We don't want to treat our package as a dependency
-        install_list.remove(UNIT_TEST_PKG)
-        # install reordered dependencies
-        for dep in install_list:
-            build_and_install(dep, False)
-        os.chdir(os.path.join(WORKSPACE, UNIT_TEST_PKG))
-        # Run package unit tests
-        build_and_install(UNIT_TEST_PKG, True)
-        if os.path.isfile(CODE_SCAN_DIR + '/meson.build'):
-            if not TEST_ONLY:
-                maybe_meson_valgrind()
+    # Determine dependencies and add them
+    dep_added = dict()
+    dep_added[UNIT_TEST_PKG] = False
 
-                # Run clang-tidy only if the project has a configuration
-                if os.path.isfile('.clang-tidy'):
-                    check_call_cmd('run-clang-tidy-8.py', '-p',
-                                   'build')
-                # Run the basic clang static analyzer otherwise
-                else:
-                    check_call_cmd('ninja', '-C', 'build',
-                                   'scan-build')
+    # Create dependency tree
+    dep_tree = DepTree(UNIT_TEST_PKG)
+    build_dep_tree(UNIT_TEST_PKG,
+                   os.path.join(WORKSPACE, UNIT_TEST_PKG),
+                   dep_added,
+                   dep_tree,
+                   BRANCH)
 
-                # Run tests through sanitizers
-                # b_lundef is needed if clang++ is CXX since it resolves the
-                # asan symbols at runtime only. We don't want to set it earlier
-                # in the build process to ensure we don't have undefined
-                # runtime code.
-                if is_sanitize_safe():
-                    check_call_cmd('meson', 'configure', 'build',
-                                   '-Db_sanitize=address,undefined',
-                                   '-Db_lundef=false')
-                    check_call_cmd('meson', 'test', '-C', 'build',
-                                   '--logbase', 'testlog-ubasan')
-                    # TODO: Fix memory sanitizer
-                    #check_call_cmd('meson', 'configure', 'build',
-                    #               '-Db_sanitize=memory')
-                    #check_call_cmd('meson', 'test', '-C', 'build'
-                    #               '--logbase', 'testlog-msan')
-                    check_call_cmd('meson', 'configure', 'build',
-                                   '-Db_sanitize=none', '-Db_lundef=true')
-                else:
-                    sys.stderr.write("###### Skipping sanitizers ######\n")
+    # Reorder Dependency Tree
+    for pkg_name, regex_str in DEPENDENCIES_REGEX.iteritems():
+        dep_tree.ReorderDeps(pkg_name, regex_str)
+    if args.verbose:
+        dep_tree.PrintTree()
 
-                # Run coverage checks
-                check_call_cmd('meson', 'configure', 'build',
-                               '-Db_coverage=true')
-                run_unit_tests_meson()
-                # Only build coverage HTML if coverage files were produced
-                for root, dirs, files in os.walk('build'):
-                    if any([f.endswith('.gcda') for f in files]):
-                        check_call_cmd('ninja', '-C', 'build',
-                                       'coverage-html')
-                        break
-                check_call_cmd('meson', 'configure', 'build',
-                               '-Db_coverage=false')
-            else:
-                run_unit_tests_meson()
+    install_list = dep_tree.GetInstallList()
 
-        else:
-            run_unit_tests()
-            if not TEST_ONLY:
-                maybe_make_valgrind()
-                maybe_make_coverage()
-        if not TEST_ONLY:
-            run_cppcheck()
+    # We don't want to treat our package as a dependency
+    install_list.remove(UNIT_TEST_PKG)
 
-        os.umask(prev_umask)
+    # Install reordered dependencies
+    for dep in install_list:
+        build_and_install(dep, False)
 
-    # Cmake
-    elif os.path.isfile(CODE_SCAN_DIR + "/CMakeLists.txt"):
-        os.chdir(os.path.join(WORKSPACE, UNIT_TEST_PKG))
-        check_call_cmd('cmake', '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON', '.')
-        check_call_cmd('cmake', '--build', '.', '--', '-j',
-                       str(multiprocessing.cpu_count()))
-        if make_target_exists('test'):
-            check_call_cmd('ctest', '.')
-        if not TEST_ONLY:
-            maybe_make_valgrind()
-            maybe_make_coverage()
-            run_cppcheck()
-            if os.path.isfile('.clang-tidy'):
-                check_call_cmd('run-clang-tidy-8.py', '-p', '.')
+    # Run package unit tests
+    build_and_install(UNIT_TEST_PKG, True)
 
-    else:
-        print "Not a supported repo for CI Tests, exit"
-        quit()
+    os.umask(prev_umask)
 
     # Run any custom CI scripts the repo has, of which there can be
     # multiple of and anywhere in the repository.