Repository: warpnet/salt-lint
Pull request #208 (2020-11-28 22:52:22)

Merge develop into main
Merge `develop` into `main`.


Patch:
diff --git a/LICENSE b/LICENSE
index c3f0a06..d3a293b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -2,7 +2,7 @@ MIT License
 
 Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 Copyright (c) 2018 Ansible by Red Hat
-Modified work Copyright (c) 2019 Warpnet B.V.
+Modified work Copyright (c) 2020 Warpnet B.V.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 36642b5..fbc3364 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ optional arguments:
 
 It's important to note that `salt-lint` accepts a list of Salt State files or a list of directories.
 
-## Docker
+## Docker & Podman
 
 salt-lint is available on [Dockerhub](https://hub.docker.com/r/warpnetbv/salt-lint).
 
@@ -77,6 +77,12 @@ Example usage:
 docker run -v $(pwd):/data:ro --entrypoint=/bin/bash -it warpnetbv/salt-lint:latest -c 'find /data -type f -name "*.sls" | xargs --no-run-if-empty salt-lint'
 ```
 
+On a system with SELinux, change `:ro` to `:Z`. Example below uses podman:
+
+```bash
+podman run -v $(pwd):/data:Z --entrypoint=/bin/bash -it warpnetbv/salt-lint:latest -c 'find /data -type f -name "*.sls" | xargs --no-run-if-empty salt-lint'
+```
+
 ## GitHub Action
 
 Salt-lint is available on the GitHub [marketplace](https://github.com/marketplace/actions/salt-lint) as a GitHub Action. The `salt-lint-action` allows you to run ``salt-lint`` with no additional options.
@@ -158,6 +164,10 @@ Optionally override the default file selection as follows:
 
 ## List of rules
 
+### Formatting
+
+Disable formatting checks using `-x formatting`
+
 Rule | Description
 :-:|:--
 [201](https://github.com/warpnet/salt-lint/wiki/201) | Trailing whitespace
@@ -172,6 +182,26 @@ Rule | Description
 [210](https://github.com/warpnet/salt-lint/wiki/210) | Numbers that start with `0` should always be encapsulated in quotation marks
 [211](https://github.com/warpnet/salt-lint/wiki/211) | `pillar.get` or `grains.get` should be formatted differently
 [212](https://github.com/warpnet/salt-lint/wiki/212) | Most files should not contain irregular spaces
+[213](https://github.com/warpnet/salt-lint/wiki/213) | SaltStack recommends using `cmd.run` together with `onchanges`, rather than `cmd.wait`
+
+### Jinja
+
+Disable jinja checks using `-x jinja`
+
+Rule | Description
+:-:|:--
+[202](https://github.com/warpnet/salt-lint/wiki/202) | Jinja statement should have spaces before and after: `{% statement %}`
+[206](https://github.com/warpnet/salt-lint/wiki/206) | Jinja variables should have spaces before and after `{{ var_name }}`
+[209](https://github.com/warpnet/salt-lint/wiki/209) | Jinja comment should have spaces before and after: `{# comment #}`
+[211](https://github.com/warpnet/salt-lint/wiki/211) | `pillar.get` or `grains.get` should be formatted differently
+
+### Deprecations
+
+Disable deprecation checks using `-x deprecation`
+
+Rule | Description
+:-:|:--
+[901](https://github.com/warpnet/salt-lint/wiki/901) | Using the `quiet` argument with `cmd.run` is deprecated. Use `output_loglevel: quiet`
 
 ## False Positives: Skipping Rules
 
diff --git a/saltlint/__init__.py b/saltlint/__init__.py
index ec9110f..0c41c14 100644
--- a/saltlint/__init__.py
+++ b/saltlint/__init__.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright (c) 2019 Warpnet B.V.
+# Copyright (c) 2020 Warpnet B.V.
 
 """A command-line utility that checks for best practices in SaltStack.
 """
diff --git a/saltlint/__main__.py b/saltlint/__main__.py
index 4484a68..716bea2 100644
--- a/saltlint/__main__.py
+++ b/saltlint/__main__.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import sys
 import errno
diff --git a/saltlint/cli.py b/saltlint/cli.py
index ab5e934..e198ea7 100644
--- a/saltlint/cli.py
+++ b/saltlint/cli.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 from __future__ import print_function
 
@@ -10,20 +10,88 @@ import sys
 import tempfile
 import codecs
 
-from saltlint import NAME, VERSION
+from saltlint import NAME, VERSION, DESCRIPTION
 from saltlint import formatters
-from saltlint.config import SaltLintConfig, SaltLintConfigError, default_rulesdir
-from saltlint.linter import RulesCollection, Runner
+from saltlint.config import Configuration, SaltLintConfigError, default_rulesdir
+from saltlint.linter.collection import RulesCollection
+from saltlint.linter.runner import Runner
 
 
 def run(args=None):
+    """Run the linter and return the exit code."""
     # Wrap `sys.stdout` in an object that automatically encodes an unicode
     # string into utf-8, in Python 2 only. The default encoding for Python 3
     # is already utf-8.
     if sys.version_info[0] < 3:
         sys.stdout = codecs.getwriter('utf-8')(sys.stdout)
 
-    parser = argparse.ArgumentParser(prog=NAME)
+    parser = init_argument_parser()
+    options = parser.parse_args(args if args is not None else sys.argv[1:])
+
+    stdin_file = None
+    file_names = set(options.files)
+    checked_files = set()
+
+    # Read input from STDIN
+    if not sys.stdin.isatty():
+        stdin_file = tempfile.NamedTemporaryFile('w', suffix='.sls', delete=False)
+        stdin_file.write(sys.stdin.read())
+        stdin_file.flush()
+        file_names.add(stdin_file.name)
+
+    # Read, parse and validate the configuration
+    options_dict = vars(options)
+    try:
+        config = Configuration(options_dict)
+    except SaltLintConfigError as exc:
+        print(exc)
+        return 2
+
+    # Show a help message on the screen
+    if not file_names and not (options.listrules or options.listtags):
+        parser.print_help(file=sys.stderr)
+        return 1
+
+    # Collect the rules from the configuration
+    collection = RulesCollection(config)
+    for rulesdir in config.rulesdirs:
+        collection.extend(RulesCollection.create_from_directory(rulesdir, config))
+
+    # Show the rules listing
+    if options.listrules:
+        print(collection)
+        return 0
+
+    # Show the tags listing
+    if options.listtags:
+        print(collection.listtags())
+        return 0
+
+    formatter = initialize_formatter(config)
+
+    problems = []
+    for file_name in file_names:
+        runner = Runner(collection, file_name, config, checked_files)
+        problems.extend(runner.run())
+
+    # Delete stdin temporary file
+    if stdin_file:
+        os.unlink(stdin_file.name)
+
+    if problems:
+        sorted_problems = sort_problems(problems)
+        formatter.process(sorted_problems)
+        return 2
+    return 0
+
+
+def init_argument_parser():
+    """Returns a new initialized argument parser."""
+    parser = argparse.ArgumentParser(prog=NAME, description=DESCRIPTION)
+
+    # The files argument is optional as STDIN is always read
+    parser.add_argument(dest='files', metavar='FILE', nargs='*', default=[],
+                        help='one or more files or paths')
 
     parser.add_argument('--version', action='version', version='%(prog)s {}'.format(VERSION))
     parser.add_argument('-L', dest='listrules', default=False,
@@ -69,75 +137,28 @@ def run(args=None):
     parser.add_argument('--severity', dest='severity', action='store_true', default=False,
                         help='add the severity to the standard output')
     parser.add_argument('-c', help='Specify configuration file to use.  Defaults to ".salt-lint"')
-    # The files argument is optional as STDIN is always read
-    parser.add_argument(dest='files', nargs='*', help='One or more files or paths.', default=[])
-
-    options = parser.parse_args(args if args is not None else sys.argv[1:])
-
-    stdin_state = None
-    states = set(options.files)
-    matches = []
-    checked_files = set()
-
-    # Read input from STDIN
-    if not sys.stdin.isatty():
-        stdin_state = tempfile.NamedTemporaryFile('w', suffix='.sls', delete=False)
-        stdin_state.write(sys.stdin.read())
-        stdin_state.flush()
-        states.add(stdin_state.name)
-
-    # Read, parse and validate the configuration
-    options_dict = vars(options)
-    try:
-        config = SaltLintConfig(options_dict)
-    except SaltLintConfigError as exc:
-        print(exc)
-        return 2
 
-    # Show a help message on the screen
-    if not states and not (options.listrules or options.listtags):
-        parser.print_help(file=sys.stderr)
-        return 1
-
-    # Collect the rules from the configuration
-    rules = RulesCollection(config)
-    for rulesdir in config.rulesdirs:
-        rules.extend(RulesCollection.create_from_directory(rulesdir, config))
-
-    # Show the rules listing
-    if options.listrules:
-        print(rules)
-        return 0
-
-    # Show the tags listing
-    if options.listtags:
-        print(rules.listtags())
-        return 0
-
-    # Define the formatter
-    if config.json:
-        formatter = formatters.JsonFormatter()
-    elif config.severity:
-        formatter = formatters.SeverityFormatter(config.colored)
-    else:
-        formatter = formatters.Formatter(config.colored)
-
-    for state in states:
-        runner = Runner(rules, state, config, checked_files)
-        matches.extend(runner.run())
-
-    # Sort the matches
-    matches.sort(key=lambda x: (x.filename, x.linenumber, x.rule.id))
-
-    # Show the matches on the screen
-    formatter.process(matches)
-
-    # Delete stdin temporary file
-    if stdin_state:
-        os.unlink(stdin_state.name)
-
-    # Return the exit code
-    if matches:
-        return 2
-
-    return 0
+    return parser
+
+
+def initialize_formatter(config):
+    """Return the initialized output formatter based upon the configuration."""
+    if config.json:  # pylint: disable=R1705
+        return formatters.JsonFormatter()
+    elif config.severity:  # pylint: disable=R1705
+        return formatters.SeverityFormatter(config.colored)
+    return formatters.Formatter(config.colored)
+
+
+def sort_problems(problems):
+    """Returns the sorted list of problems."""
+    # Note: sort() doesn't return the sorted list; rather, it sorts the list
+    # in place
+    problems.sort(
+        key=lambda problem: (
+            problem.filename,
+            problem.linenumber,
+            problem.rule.id
+        )
+    )
+    return problems
diff --git a/saltlint/config.py b/saltlint/config.py
index 0c61344..9d23c8b 100644
--- a/saltlint/config.py
+++ b/saltlint/config.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright (c) 2019 Warpnet B.V.
+# Copyright (c) 2020 Warpnet B.V.
 
 import os
 import sys
@@ -18,7 +18,7 @@ class SaltLintConfigError(Exception):
     pass
 
 
-class SaltLintConfig(object):
+class Configuration(object):
 
     def __init__(self, options={}):
         self._options = options
diff --git a/saltlint/formatters/__init__.py b/saltlint/formatters/__init__.py
index aad57cd..1cc6888 100644
--- a/saltlint/formatters/__init__.py
+++ b/saltlint/formatters/__init__.py
@@ -1,108 +1,6 @@
 # -*- coding: utf-8 -*-
-# Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
-# Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
-import json
-
-
-def get_colors(use=True):
-    """
-    Return the colors as a dict, pass False to return the colors as empty
-    strings.
-    """
-    colors = {
-        "BLACK": "\033[0;30m",
-        "DARK_GRAY": "\033[1;30m",
-        "RED": "\033[0;31m",
-        "LIGHT_RED": "\033[1;31m",
-        "GREEN": "\033[0;32m",
-        "LIGHT_GREEN": "\033[1;32m",
-        "BLUE": "\033[0;34m",
-        "LIGHT_BLUE": "\033[1;34m",
-        "MAGENTA": "\033[0;35m",
-        "LIGHT_MAGENTA": "\033[1;35m",
-        "CYAN": "\033[0;36m",
-        "LIGHT_CYAN": "\033[1;36m",
-        "LIGHT_GRAY": "\033[0;37m",
-        "WHITE": "\033[1;37m",
-        "DEFAULT_COLOR": "\033[00m",
-        "ENDC": "\033[0m",
-    }
-
-    if not use:
-        for color in colors:
-            colors[color] = ''
-
-    return colors
-
-
-class BaseFormatter(object):
-
-    def __init__(self, colored=False):
-        self.colored = colored
-
-    def process(self, matches):
-        for match in matches:
-            print(self.format(match))
-
-    def format(self, match):
-        raise NotImplementedError()
-
-
-class Formatter(BaseFormatter):
-
-    def format(self, match):
-        formatstr = u"{0} {1}\n{2}:{3}\n{4}\n"
-
-        color = get_colors(self.colored)
-        return formatstr.format(
-            u'{0}[{1}]{2}'.format(color['RED'], match.rule.id,
-                                  color['ENDC']),
-            u'{0}{1}{2}'.format(color['LIGHT_RED'], match.message,
-                                color['ENDC']),
-            u'{0}{1}{2}'.format(color['BLUE'], match.filename,
-                                color['ENDC']),
-            u'{0}{1}{2}'.format(color['CYAN'], str(match.linenumber),
-                                color['ENDC']),
-            u'{0}{1}{2}'.format(color['MAGENTA'], match.line, color['ENDC'])
-        )
-
-
-class SeverityFormatter(BaseFormatter):
-
-    def format(self, match):
-        formatstr = u"{0} {sev} {1}\n{2}:{3}\n{4}\n"
-
-        color = get_colors(self.colored)
-        return formatstr.format(
-            u'{0}[{1}]{2}'.format(color['RED'], match.rule.id,
-                                  color['ENDC']),
-            u'{0}{1}{2}'.format(color['LIGHT_RED'], match.message,
-                                color['ENDC']),
-            u'{0}{1}{2}'.format(color['BLUE'], match.filename,
-                                color['ENDC']),
-            u'{0}{1}{2}'.format(color['CYAN'], str(match.linenumber),
-                                color['ENDC']),
-            u'{0}{1}{2}'.format(color['MAGENTA'], match.line, color['ENDC']),
-            sev=u'{0}[{1}]{2}'.format(color['RED'], match.rule.severity,
-                                      color['ENDC'])
-        )
-
-
-class JsonFormatter(BaseFormatter):
-    def process(self, matches, *args, **kwargs):
-        items = []
-        for match in matches:
-            items.append(self.format(match))
-        print(json.dumps(items))
-
-    def format(self, match):
-        return {
-            'id': match.rule.id,
-            'message': match.message,
-            'filename': match.filename,
-            'linenumber': match.linenumber,
-            'line': match.line,
-            'severity': match.rule.severity,
-        }
+from saltlint.formatters.default import Formatter
+from saltlint.formatters.severity import SeverityFormatter
+from saltlint.formatters.json import JsonFormatter
diff --git a/saltlint/formatters/base.py b/saltlint/formatters/base.py
new file mode 100644
index 0000000..e01ed4b
--- /dev/null
+++ b/saltlint/formatters/base.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
+# Copyright (c) 2018 Ansible by Red Hat
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+
+class BaseFormatter(object):
+
+    def __init__(self, colored=False):
+        self.colored = colored
+
+    def process(self, problems):
+        for problem in problems:
+            print(self.format(problem))
+
+    def format(self, problem):
+        raise NotImplementedError()
+
+    def get_colors(self, use=True):
+        """
+        Return the colors as a dict, pass False to return the colors as empty
+        strings.
+        """
+        colors = {
+            "BLACK": "\033[0;30m",
+            "DARK_GRAY": "\033[1;30m",
+            "RED": "\033[0;31m",
+            "LIGHT_RED": "\033[1;31m",
+            "GREEN": "\033[0;32m",
+            "LIGHT_GREEN": "\033[1;32m",
+            "BLUE": "\033[0;34m",
+            "LIGHT_BLUE": "\033[1;34m",
+            "MAGENTA": "\033[0;35m",
+            "LIGHT_MAGENTA": "\033[1;35m",
+            "CYAN": "\033[0;36m",
+            "LIGHT_CYAN": "\033[1;36m",
+            "LIGHT_GRAY": "\033[0;37m",
+            "WHITE": "\033[1;37m",
+            "DEFAULT_COLOR": "\033[00m",
+            "ENDC": "\033[0m",
+        }
+
+        if not use:
+            for color in colors:
+                colors[color] = ''
+
+        return colors
diff --git a/saltlint/formatters/default.py b/saltlint/formatters/default.py
new file mode 100644
index 0000000..4b5ad50
--- /dev/null
+++ b/saltlint/formatters/default.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
+# Copyright (c) 2018 Ansible by Red Hat
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+
+from saltlint.formatters.base import BaseFormatter
+
+
+class Formatter(BaseFormatter):
+
+    def format(self, problem):
+        formatstr = u"{0} {1}\n{2}:{3}\n{4}\n"
+
+        color = self.get_colors(self.colored)
+        return formatstr.format(
+            u'{0}[{1}]{2}'.format(color['RED'], problem.rule.id,
+                                  color['ENDC']),
+            u'{0}{1}{2}'.format(color['LIGHT_RED'], problem.message,
+                                color['ENDC']),
+            u'{0}{1}{2}'.format(color['BLUE'], problem.filename,
+                                color['ENDC']),
+            u'{0}{1}{2}'.format(color['CYAN'], str(problem.linenumber),
+                                color['ENDC']),
+            u'{0}{1}{2}'.format(color['MAGENTA'], problem.line, color['ENDC'])
+        )
diff --git a/saltlint/formatters/json.py b/saltlint/formatters/json.py
new file mode 100644
index 0000000..c7134fb
--- /dev/null
+++ b/saltlint/formatters/json.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
+# Copyright (c) 2018 Ansible by Red Hat
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+import json
+
+from saltlint.formatters.base import BaseFormatter
+
+
+class JsonFormatter(BaseFormatter):
+    def process(self, problems, *args, **kwargs):
+        items = []
+        for problem in problems:
+            items.append(self.format(problem))
+        print(json.dumps(items))
+
+    def format(self, problem):
+        return {
+            'id': problem.rule.id,
+            'message': problem.message,
+            'filename': problem.filename,
+            'linenumber': problem.linenumber,
+            'line': problem.line,
+            'severity': problem.rule.severity,
+        }
diff --git a/saltlint/formatters/severity.py b/saltlint/formatters/severity.py
new file mode 100644
index 0000000..904521a
--- /dev/null
+++ b/saltlint/formatters/severity.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
+# Copyright (c) 2018 Ansible by Red Hat
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+from saltlint.formatters.base import BaseFormatter
+
+
+class SeverityFormatter(BaseFormatter):
+
+    def format(self, problem):
+        formatstr = u"{0} {sev} {1}\n{2}:{3}\n{4}\n"
+
+        color = self.get_colors(self.colored)
+        return formatstr.format(
+            u'{0}[{1}]{2}'.format(color['RED'], problem.rule.id,
+                                  color['ENDC']),
+            u'{0}{1}{2}'.format(color['LIGHT_RED'], problem.message,
+                                color['ENDC']),
+            u'{0}{1}{2}'.format(color['BLUE'], problem.filename,
+                                color['ENDC']),
+            u'{0}{1}{2}'.format(color['CYAN'], str(problem.linenumber),
+                                color['ENDC']),
+            u'{0}{1}{2}'.format(color['MAGENTA'], problem.line, color['ENDC']),
+            sev=u'{0}[{1}]{2}'.format(color['RED'], problem.rule.severity,
+                                      color['ENDC'])
+        )
diff --git a/saltlint/linter.py b/saltlint/linter.py
deleted file mode 100644
index 83e12be..0000000
--- a/saltlint/linter.py
+++ /dev/null
@@ -1,221 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
-# Modified work Copyright (c) 2019 Warpnet B.V.
-
-from __future__ import print_function
-
-from collections import defaultdict
-import os
-import re
-import sys
-import codecs
-import six
-
-import saltlint.utils
-from saltlint.config import SaltLintConfig
-
-
-class SaltLintRule(object):
-
-    def __init__(self, config=None):
-        self.config = config
-
-    def __repr__(self):
-        return self.id + ": " + self.shortdesc
-
-    def verbose(self):
-        return self.id + ": " + self.shortdesc + "\n " + self.description
-
-    match = None
-    matchtext = None
-
-    @staticmethod
-    def unjinja(text):
-        return re.sub(r"{{[^}]*}}", "JINJA_VAR", text)
-
-    def matchlines(self, file, text):
-        matches = []
-        if not self.match:
-            return matches
-
-        # arrays are 0-based, line numbers are 1-based
-        # so use prev_line_no as the counter
-        for (prev_line_no, line) in enumerate(text.split("\n")):
-            if line.lstrip().startswith('#'):
-                continue
-
-            rule_id_list = saltlint.utils.get_rule_skips_from_line(line)
-            if self.id in rule_id_list:
-                continue
-
-            result = self.match(file, line)
-            if not result:
-                continue
-            message = None
-            if isinstance(result, six.string_types):
-                message = result
-            matches.append(Match(prev_line_no+1, line,
-                                 file['path'], self, message))
-
-        return matches
-
-    def matchfulltext(self, file, text):
-        matches = []
-        if not self.matchtext:
-            return matches
-
-        results = self.matchtext(file, text)
-
-        for line, section, message in results:
-            matches.append(Match(line, section, file['path'], self, message))
-
-        return matches
-
-
-class RulesCollection(object):
-
-    def __init__(self, config=SaltLintConfig()):
-        self.rules = []
-        self.config = config
-
-    def register(self, obj):
-        if not any(rule.id == obj.id for rule in self.rules):
-            self.rules.append(obj)
-
-    def __iter__(self):
-        return iter(self.rules)
-
-    def __len__(self):
-        return len(self.rules)
-
-    def extend(self, more):
-        self.rules.extend(more)
-
-    def run(self, statefile, tags=set(), skip_list=frozenset()):
-        text = ""
-        matches = []
-
-        try:
-            with codecs.open(statefile['path'], mode='rb', encoding='utf-8') as f:
-                text = f.read()
-        except IOError as e:
-            print("WARNING: Couldn't open %s - %s" %
-                  (statefile['path'], e.strerror),
-                  file=sys.stderr)
-            return matches
-
-        for rule in self.rules:
-            skip = False
-            if not tags or not set(rule.tags).union([rule.id]).isdisjoint(tags):
-                rule_definition = set(rule.tags)
-                rule_definition.add(rule.id)
-
-                # Check if the file is in the rule specific ignore list.
-                for definition in rule_definition:
-                    if self.config.is_file_ignored(statefile['path'], definition):
-                        skip = True
-                        break
-
-                if not skip and set(rule_definition).isdisjoint(skip_list):
-                    matches.extend(rule.matchlines(statefile, text))
-                    matches.extend(rule.matchfulltext(statefile, text))
-
-        return matches
-
-    def __repr__(self):
-        return "\n".join([rule.verbose()
-                          for rule in sorted(self.rules, key=lambda x: x.id)])
-
-    def listtags(self):
-        tags = defaultdict(list)
-        for rule in self.rules:
-            for tag in rule.tags:
-                tags[tag].append("[{0}]".format(rule.id))
-        results = []
-        for tag in sorted(tags):
-            results.append("{0} {1}".format(tag, tags[tag]))
-        return "\n".join(results)
-
-    @classmethod
-    def create_from_directory(cls, rulesdir, config):
-        result = cls(config)
-        result.rules = saltlint.utils.load_plugins(os.path.expanduser(rulesdir), config)
-        return result
-
-
-class Match(object):
-
-    def __init__(self, linenumber, line, filename, rule, message=None):
-        self.linenumber = linenumber
-        self.line = line
-        self.filename = filename
-        self.rule = rule
-        self.message = message or rule.shortdesc
-
-    def __repr__(self):
-        formatstr = u"[{0}] ({1}) matched {2}:{3} {4}"
-        return formatstr.format(self.rule.id, self.message,
-                                self.filename, self.linenumber, self.line)
-
-
-class Runner(object):
-
-    def __init__(self, rules, state, config, checked_files=None):
-        self.rules = rules
-
-        self.states = set()
-        # Assume state is directory
-        if os.path.isdir(state):
-            self.states.add((os.path.join(state, 'init.sls'), 'state'))
-        else:
-            self.states.add((state, 'state'))
-
-        # Get configuration options
-        self.config = config
-        self.tags = config.tags
-        self.skip_list = config.skip_list
-        self.verbosity = config.verbosity
-        self._update_exclude_paths(config.exclude_paths)
-
-        # Set the checked files
-        if checked_files is None:
-            checked_files = set()
-        self.checked_files = checked_files
-
-    def _update_exclude_paths(self, exclude_paths):
-        if exclude_paths:
-            # These will be (potentially) relative paths
-            paths = [s.strip() for s in exclude_paths]
-            self.exclude_paths = paths + [os.path.abspath(p) for p in paths]
-        else:
-            self.exclude_paths = []
-
-    def is_excluded(self, file_path):
-        # Any will short-circuit as soon as something returns True, but will
-        # be poor performance for the case where the path under question is
-        # not excluded.
-        return any(file_path.startswith(path) for path in self.exclude_paths)
-
-    def run(self):
-        files = []
-        for state in self.states:
-            if self.is_excluded(state[0]):
-                continue
-            files.append({'path': state[0], 'type': state[1]})
-
-        matches = []
-
-        # remove duplicates from files list
-        files = [value for n, value in enumerate(files) if value not in files[:n]]
-
-        # remove files that have already been checked
-        files = [x for x in files if x['path'] not in self.checked_files]
-        for file in files:
-            if self.verbosity > 0:
-                print("Examining %s of type %s" % (file['path'], file['type']))
-            matches.extend(self.rules.run(file, tags=set(self.tags),
-                                          skip_list=self.skip_list))
-        # update list of checked files
-        self.checked_files.update([x['path'] for x in files])
-
-        return matches
diff --git a/saltlint/linter/__init__.py b/saltlint/linter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/saltlint/linter/collection.py b/saltlint/linter/collection.py
new file mode 100644
index 0000000..bf09619
--- /dev/null
+++ b/saltlint/linter/collection.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+from __future__ import print_function
+
+from collections import defaultdict
+import os
+import codecs
+import sys
+
+from saltlint.config import Configuration
+from saltlint.utils import load_plugins
+
+
+class RulesCollection(object):
+
+    def __init__(self, config=Configuration()):
+        self.rules = []
+        self.config = config
+
+    def register(self, obj):
+        if not any(rule.id == obj.id for rule in self.rules):
+            self.rules.append(obj)
+
+    def __iter__(self):
+        return iter(self.rules)
+
+    def __len__(self):
+        return len(self.rules)
+
+    def extend(self, more):
+        self.rules.extend(more)
+
+    def run(self, statefile, tags=set(), skip_list=frozenset()):
+        text = ""
+        matches = []
+
+        try:
+            with codecs.open(statefile['path'], mode='rb', encoding='utf-8') as f:
+                text = f.read()
+        except IOError as e:
+            print("WARNING: Couldn't open %s - %s" %
+                  (statefile['path'], e.strerror),
+                  file=sys.stderr)
+            return matches
+
+        for rule in self.rules:
+            skip = False
+            if not tags or not set(rule.tags).union([rule.id]).isdisjoint(tags):
+                rule_definition = set(rule.tags)
+                rule_definition.add(rule.id)
+
+                # Check if the file is in the rule specific ignore list.
+                for definition in rule_definition:
+                    if self.config.is_file_ignored(statefile['path'], definition):
+                        skip = True
+                        break
+
+                if not skip and set(rule_definition).isdisjoint(skip_list):
+                    matches.extend(rule.matchlines(statefile, text))
+                    matches.extend(rule.matchfulltext(statefile, text))
+
+        return matches
+
+    def __repr__(self):
+        return "\n".join([rule.verbose()
+                          for rule in sorted(self.rules, key=lambda x: x.id)])
+
+    def listtags(self):
+        tags = defaultdict(list)
+        for rule in self.rules:
+            for tag in rule.tags:
+                tags[tag].append("[{0}]".format(rule.id))
+        results = []
+        for tag in sorted(tags):
+            results.append("{0} {1}".format(tag, tags[tag]))
+        return "\n".join(results)
+
+    @classmethod
+    def create_from_directory(cls, rulesdir, config):
+        result = cls(config)
+        result.rules = load_plugins(os.path.expanduser(rulesdir), config)
+        return result
diff --git a/saltlint/linter/match.py b/saltlint/linter/match.py
new file mode 100644
index 0000000..f3aa709
--- /dev/null
+++ b/saltlint/linter/match.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+
+class Match(object):
+
+    def __init__(self, linenumber, line, filename, rule, message=None):
+        self.linenumber = linenumber
+        self.line = line
+        self.filename = filename
+        self.rule = rule
+        self.message = message or rule.shortdesc
+
+    def __repr__(self):
+        formatstr = u"[{0}] ({1}) matched {2}:{3} {4}"
+        return formatstr.format(self.rule.id, self.message,
+                                self.filename, self.linenumber, self.line)
diff --git a/saltlint/linter/rule.py b/saltlint/linter/rule.py
new file mode 100644
index 0000000..bf95848
--- /dev/null
+++ b/saltlint/linter/rule.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+import re
+import six
+
+from saltlint.utils import get_rule_skips_from_line
+from saltlint.linter.match import Match
+
+
+class Rule(object):
+
+    def __init__(self, config=None):
+        self.config = config
+
+    def __repr__(self):
+        return self.id + ": " + self.shortdesc
+
+    def verbose(self):
+        return self.id + ": " + self.shortdesc + "\n " + self.description
+
+    match = None
+    matchtext = None
+
+    @staticmethod
+    def unjinja(text):
+        return re.sub(r"{{[^}]*}}", "JINJA_VAR", text)
+
+    def matchlines(self, file, text):
+        matches = []
+        if not self.match:
+            return matches
+
+        # arrays are 0-based, line numbers are 1-based
+        # so use prev_line_no as the counter
+        for (prev_line_no, line) in enumerate(text.split("\n")):
+            if line.lstrip().startswith('#'):
+                continue
+
+            rule_id_list = get_rule_skips_from_line(line)
+            if self.id in rule_id_list:
+                continue
+
+            result = self.match(file, line)
+            if not result:
+                continue
+            message = None
+            if isinstance(result, six.string_types):
+                message = result
+            matches.append(Match(prev_line_no+1, line,
+                                 file['path'], self, message))
+
+        return matches
+
+    def matchfulltext(self, file, text):
+        matches = []
+        if not self.matchtext:
+            return matches
+
+        results = self.matchtext(file, text)
+
+        for line, section, message in results:
+            matches.append(Match(line, section, file['path'], self, message))
+
+        return matches
diff --git a/saltlint/linter/runner.py b/saltlint/linter/runner.py
new file mode 100644
index 0000000..3add57d
--- /dev/null
+++ b/saltlint/linter/runner.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
+# Modified work Copyright (c) 2020 Warpnet B.V.
+
+from __future__ import print_function
+
+import os
+
+
+class Runner(object):
+
+    def __init__(self, collection, file_name, config, checked_files=None):
+        self.collection = collection
+
+        self.states = set()
+        # Assume the provided file name is a directory
+        if os.path.isdir(file_name):
+            self.states.add((os.path.join(file_name, 'init.sls'), 'state'))
+        else:
+            self.states.add((file_name, 'state'))
+
+        # Get configuration options
+        self.config = config
+        self.tags = config.tags
+        self.skip_list = config.skip_list
+        self.verbosity = config.verbosity
+        self._update_exclude_paths(config.exclude_paths)
+
+        # Set the checked files
+        if checked_files is None:
+            checked_files = set()
+        self.checked_files = checked_files
+
+    def _update_exclude_paths(self, exclude_paths):
+        if exclude_paths:
+            # These will be (potentially) relative paths
+            paths = [path.strip() for path in exclude_paths]
+            self.exclude_paths = paths + [os.path.abspath(path) for path in paths]
+        self.exclude_paths = []
+
+    def is_excluded(self, file_path):
+        # Any will short-circuit as soon as something returns True, but will
+        # be poor performance for the case where the path under question is
+        # not excluded.
+        return any(file_path.startswith(path) for path in self.exclude_paths)
+
+    def is_already_checked(self, file_path):
+        return file_path in self.checked_files
+
+    def run(self):
+        files = []
+        for index, state in enumerate(self.states):
+            file_path = state[0]
+            file_type = state[1]
+            file_dict = {'path': file_path, 'type': file_type}
+            # Skip excluded files
+            if self.is_excluded(file_path):
+                continue
+            # Skip already checked files
+            if self.is_already_checked(file_path):
+                continue
+            # Skip duplicates
+            if file_dict in files[:index]:
+                continue
+            files.append(file_dict)
+
+        problems = []
+        for file in files:
+            if self.verbosity > 0:
+                print("Examining %s of type %s" % (file['path'], file['type']))
+            problems.extend(self.collection.run(file, tags=set(self.tags),
+                                                skip_list=self.skip_list))
+        # Update list of checked files
+        self.checked_files.update([file_dict['path'] for file_dict in files])
+
+        return problems
diff --git a/saltlint/rules/CmdRunQuietRule.py b/saltlint/rules/CmdRunQuietRule.py
new file mode 100644
index 0000000..5dd68ce
--- /dev/null
+++ b/saltlint/rules/CmdRunQuietRule.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import re
+from saltlint.linter.rule import Rule
+from saltlint.utils import get_rule_skips_from_text
+
+class CmdRunQuietRule(Rule):
+    id = '901'
+    shortdesc = 'Using the quiet argument with cmd.run is deprecated. Use output_loglevel: quiet'
+    description = 'Using the quiet argument with cmd.run is deprecated. Use output_loglevel: quiet'
+
+    severity = 'HIGH'
+    tags = ['deprecation']
+    version_added = 'develop'
+
+    regex = re.compile(r"^.+\n^\s{2}cmd\.run:(?:\n.+)+\n^\s{4}- quiet\s?.*", re.MULTILINE)
+
+    def matchtext(self, file, text):
+        results = []
+
+        for match in re.finditer(self.regex, text):
+            # Get the location of the regex match
+            start = match.start()
+            end = match.end()
+
+            # Get the line number of the last character
+            lines = text[:end].splitlines()
+            line_no = len(lines)
+
+            # Skip result if noqa for this rule ID is found in section
+            section = text[start:end]
+            if self.id in get_rule_skips_from_text(section):
+                continue
+
+            # Append the match to the results
+            results.append((line_no, lines[-1], self.shortdesc))
+
+        return results
diff --git a/saltlint/rules/CmdWaitRecommendRule.py b/saltlint/rules/CmdWaitRecommendRule.py
new file mode 100644
index 0000000..90ccb12
--- /dev/null
+++ b/saltlint/rules/CmdWaitRecommendRule.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import re
+from saltlint.linter.rule import Rule
+
+
+class CmdWaitRecommendRule(Rule):
+    id = '213'
+    shortdesc = 'SaltStack recommends using cmd.run together with onchanges, rather than cmd.wait'
+    description = 'SaltStack recommends using cmd.run together with onchanges, rather than cmd.wait'
+
+    severity = 'LOW'
+    tags = ['formatting']
+    version_added = 'develop'
+
+    regex = re.compile(r"^\s{2}cmd\.wait:(\s+)?$")
+
+    def match(self, file, line):
+        return self.regex.search(line)
diff --git a/saltlint/rules/FileExtensionRule.py b/saltlint/rules/FileExtensionRule.py
index e5c1222..c8157e5 100644
--- a/saltlint/rules/FileExtensionRule.py
+++ b/saltlint/rules/FileExtensionRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Tsukinowa Inc. <info@tsukinowa.jp>
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import os
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class FileExtensionRule(SaltLintRule):
+class FileExtensionRule(Rule):
     id = '205'
     shortdesc = 'Use ".sls" as a Salt State file extension'
     description = 'Salt State files should have the ".sls" extension'
diff --git a/saltlint/rules/FileModeLeadingZeroRule.py b/saltlint/rules/FileModeLeadingZeroRule.py
index c8fd53c..226bea3 100644
--- a/saltlint/rules/FileModeLeadingZeroRule.py
+++ b/saltlint/rules/FileModeLeadingZeroRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class FileModeLeadingZeroRule(SaltLintRule):
+class FileModeLeadingZeroRule(Rule):
     id = '208'
     shortdesc = 'File modes should always contain a leading zero'
     description = 'File modes should always contain a leading zero'
diff --git a/saltlint/rules/FileModeQuotationRule.py b/saltlint/rules/FileModeQuotationRule.py
index c0329e6..e449c0c 100644
--- a/saltlint/rules/FileModeQuotationRule.py
+++ b/saltlint/rules/FileModeQuotationRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class FileModeQuotationRule(SaltLintRule):
+class FileModeQuotationRule(Rule):
     id = '207'
     shortdesc = 'File modes should always be encapsulated in quotation marks'
     description = 'File modes should always be encapsulated in quotation marks'
diff --git a/saltlint/rules/JinjaCommentHasSpacesRule.py b/saltlint/rules/JinjaCommentHasSpacesRule.py
index 1ff70ce..6caa1b6 100644
--- a/saltlint/rules/JinjaCommentHasSpacesRule.py
+++ b/saltlint/rules/JinjaCommentHasSpacesRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class JinjaCommentHasSpacesRule(SaltLintRule):
+class JinjaCommentHasSpacesRule(Rule):
     id = '209'
     shortdesc = 'Jinja comment should have spaces before and after: {# comment #}'
     description = 'Jinja comment should have spaces before and after: ``{# comment #}``'
diff --git a/saltlint/rules/JinjaPillarGrainsGetFormatRule.py b/saltlint/rules/JinjaPillarGrainsGetFormatRule.py
index b65d7d4..efd8181 100644
--- a/saltlint/rules/JinjaPillarGrainsGetFormatRule.py
+++ b/saltlint/rules/JinjaPillarGrainsGetFormatRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class JinjaPillarGrainsGetFormatRule(SaltLintRule):
+class JinjaPillarGrainsGetFormatRule(Rule):
     id = '211'
     shortdesc = 'pillar.get or grains.get should be formatted differently'
     description = "pillar.get and grains.get should always be formatted " \
diff --git a/saltlint/rules/JinjaStatementHasSpacesRule.py b/saltlint/rules/JinjaStatementHasSpacesRule.py
index 771d471..6bcf290 100644
--- a/saltlint/rules/JinjaStatementHasSpacesRule.py
+++ b/saltlint/rules/JinjaStatementHasSpacesRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class JinjaStatementHasSpacesRule(SaltLintRule):
+class JinjaStatementHasSpacesRule(Rule):
     id = '202'
     shortdesc = 'Jinja statement should have spaces before and after: {% statement %}'
     description = 'Jinja statement should have spaces before and after: ``{% statement %}``'
diff --git a/saltlint/rules/JinjaVariableHasSpacesRule.py b/saltlint/rules/JinjaVariableHasSpacesRule.py
index 0941753..f864fce 100644
--- a/saltlint/rules/JinjaVariableHasSpacesRule.py
+++ b/saltlint/rules/JinjaVariableHasSpacesRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class JinjaVariableHasSpacesRule(SaltLintRule):
+class JinjaVariableHasSpacesRule(Rule):
     id = '206'
     shortdesc = 'Jinja variables should have spaces before and after: {{ var_name }}'
     description = 'Jinja variables should have spaces before and after: ``{{ var_name }}``'
diff --git a/saltlint/rules/LineTooLongRule.py b/saltlint/rules/LineTooLongRule.py
index d3b5537..aa8cfd2 100644
--- a/saltlint/rules/LineTooLongRule.py
+++ b/saltlint/rules/LineTooLongRule.py
@@ -1,12 +1,12 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class LineTooLongRule(SaltLintRule):
+class LineTooLongRule(Rule):
     id = '204'
     shortdesc = 'Lines should be no longer that 160 chars'
     description = (
diff --git a/saltlint/rules/NoIrregularSpacesRule.py b/saltlint/rules/NoIrregularSpacesRule.py
index 8b77506..53a371f 100644
--- a/saltlint/rules/NoIrregularSpacesRule.py
+++ b/saltlint/rules/NoIrregularSpacesRule.py
@@ -1,9 +1,10 @@
 # -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
 
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class NoIrregularSpacesRule(SaltLintRule):
+class NoIrregularSpacesRule(Rule):
     id = '212'
     shortdesc = 'Most files should not contain irregular spaces'
     description = 'Irregular spaces can cause unexpected display issues, use spaces'
diff --git a/saltlint/rules/NoTabsRule.py b/saltlint/rules/NoTabsRule.py
index a143f52..57bfa90 100644
--- a/saltlint/rules/NoTabsRule.py
+++ b/saltlint/rules/NoTabsRule.py
@@ -1,12 +1,12 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class NoTabsRule(SaltLintRule):
+class NoTabsRule(Rule):
     id = '203'
     shortdesc = 'Most files should not contain tabs'
     description = 'Tabs can cause unexpected display issues, use spaces'
diff --git a/saltlint/rules/TrailingWhitespaceRule.py b/saltlint/rules/TrailingWhitespaceRule.py
index 199e3d5..6ec294d 100644
--- a/saltlint/rules/TrailingWhitespaceRule.py
+++ b/saltlint/rules/TrailingWhitespaceRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class TrailingWhitespaceRule(SaltLintRule):
+class TrailingWhitespaceRule(Rule):
     id = '201'
     shortdesc = 'Trailing whitespace'
     description = 'There should not be any trailing whitespace'
diff --git a/saltlint/rules/YamlHasOctalValueRule.py b/saltlint/rules/YamlHasOctalValueRule.py
index b40d96b..d39477c 100644
--- a/saltlint/rules/YamlHasOctalValueRule.py
+++ b/saltlint/rules/YamlHasOctalValueRule.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2016 Will Thames and contributors
 # Copyright (c) 2018 Ansible Project
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import re
-from saltlint.linter import SaltLintRule
+from saltlint.linter.rule import Rule
 
 
-class YamlHasOctalValueRule(SaltLintRule):
+class YamlHasOctalValueRule(Rule):
     id = '210'
     shortdesc = 'Numbers that start with `0` should always be encapsulated in quotation marks'
     description = 'Numbers that start with `0` should always be encapsulated in quotation marks'
@@ -15,7 +15,7 @@ class YamlHasOctalValueRule(SaltLintRule):
     tags = ['formatting']
     version_added = 'v0.0.6'
 
-    bracket_regex = re.compile(r"(?<=:)\s{0,}0[0-9]{1,}\s{0,}((?={#)|(?=#)|(?=$))")
+    bracket_regex = re.compile(r"^[^:]+:\s{0,}0[0-9]{1,}\s{0,}((?={#)|(?=#)|(?=$))")
 
     def match(self, file, line):
         return self.bracket_regex.search(line)
diff --git a/saltlint/utils.py b/saltlint/utils.py
index 6d01e0e..dbcdf23 100644
--- a/saltlint/utils.py
+++ b/saltlint/utils.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import glob
 import imp
@@ -31,3 +31,12 @@ def get_rule_skips_from_line(line):
         noqa_text = line.split('# noqa')[1]
         rule_id_list = noqa_text.split()
     return rule_id_list
+
+
+def get_rule_skips_from_text(text):
+    rule_id_list = []
+    for line in text.splitlines():
+        rule_id_list.extend(get_rule_skips_from_line(line))
+
+    # Return a list of unique ids
+    return list(set(rule_id_list))


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 316e73a..6c72cf6 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,11 +1,11 @@
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import tempfile
 
-from saltlint.linter import Runner
-from saltlint.config import SaltLintConfig
+from saltlint.linter.runner import Runner
+from saltlint.config import Configuration
 
 
 class RunFromText(object):
@@ -15,7 +15,7 @@ class RunFromText(object):
         self.collection = collection
 
     def _call_runner(self, path):
-        runner = Runner(self.collection, path, SaltLintConfig())
+        runner = Runner(self.collection, path, Configuration())
         return runner.run()
 
     def run_state(self, state_text):
diff --git a/tests/unit/TestCmdRunQuietRule.py b/tests/unit/TestCmdRunQuietRule.py
new file mode 100644
index 0000000..ffb4c55
--- /dev/null
+++ b/tests/unit/TestCmdRunQuietRule.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import unittest
+
+from saltlint.linter.collection import RulesCollection
+from saltlint.rules.CmdRunQuietRule import CmdRunQuietRule
+from tests import RunFromText
+
+
+GOOD_QUIET_STATE = '''
+getpip:
+  cmd.run:
+    - name: /usr/bin/python /usr/local/sbin/get-pip.py
+    - unless: which pip
+    - require:
+      - pkg: python
+      - file: /usr/local/sbin/get-pip.py
+    - output_loglevel: quiet
+'''
+
+BAD_QUIET_STATE = '''
+getpip:
+  cmd.run:
+    - name: /usr/bin/python /usr/local/sbin/get-pip.py
+    - unless: which pip
+    - require:
+      - pkg: python
+      - file: /usr/local/sbin/get-pip.py
+    - quiet  # This is the ninth line
+
+getpip2:
+  cmd.run:
+    - name: /usr/bin/python /usr/local/sbin/get-pip.py
+    - quiet
+
+getpip3:
+  cmd.run:
+    - name: /usr/bin/python /usr/local/sbin/get-pip.py
+    - quiet # noqa: 901
+'''
+
+class TestCmdRunQuietRule(unittest.TestCase):
+    collection = RulesCollection()
+
+    def setUp(self):
+        self.collection.register(CmdRunQuietRule())
+
+    def test_statement_positive(self):
+        runner = RunFromText(self.collection)
+        results = runner.run_state(GOOD_QUIET_STATE)
+        self.assertEqual(0, len(results))
+
+    def test_statement_negative(self):
+        runner = RunFromText(self.collection)
+        results = runner.run_state(BAD_QUIET_STATE)
+        self.assertEqual(2, len(results))
+
+        # Check line numbers of the results
+        self.assertEqual(9, results[0].linenumber)
+        self.assertEqual(14, results[1].linenumber)
diff --git a/tests/unit/TestCmdWaitRecommendRule.py b/tests/unit/TestCmdWaitRecommendRule.py
new file mode 100644
index 0000000..c24ea2f
--- /dev/null
+++ b/tests/unit/TestCmdWaitRecommendRule.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
+
+import unittest
+
+from saltlint.linter.collection import RulesCollection
+from saltlint.rules.CmdWaitRecommendRule import CmdWaitRecommendRule
+from tests import RunFromText
+
+
+GOOD_CMD_STATE = '''
+run_postinstall:
+  cmd.run:
+    - name: echo hello
+    - cwd: /
+    - onchanges:
+        - pkg: mycustompkg
+'''
+
+BAD_CMD_STATE = '''
+run_postinstall:
+  cmd.wait:
+    - name: /usr/local/bin/postinstall.sh
+    - watch:
+      - pkg: mycustompkg
+'''
+
+class TestCmdWaitRecommendRule(unittest.TestCase):
+    collection = RulesCollection()
+
+    def setUp(self):
+        self.collection.register(CmdWaitRecommendRule())
+
+    def test_statement_positive(self):
+        runner = RunFromText(self.collection)
+        results = runner.run_state(GOOD_CMD_STATE)
+        self.assertEqual(0, len(results))
+
+    def test_statement_negative(self):
+        runner = RunFromText(self.collection)
+        results = runner.run_state(BAD_CMD_STATE)
+        self.assertEqual(1, len(results))
diff --git a/tests/unit/TestConfig.py b/tests/unit/TestConfig.py
index 4cde49f..cead988 100644
--- a/tests/unit/TestConfig.py
+++ b/tests/unit/TestConfig.py
@@ -1,10 +1,10 @@
 # -*- coding: utf-8 -*-
-# Copyright (c) 2019 Warpnet B.V.
+# Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 import tempfile
 
-from saltlint.config import SaltLintConfig
+from saltlint.config import Configuration
 
 
 SALT_LINT_CONFIG = '''
@@ -38,7 +38,7 @@ class TestConfig(unittest.TestCase):
 
         # Specify the temporary file as if it was passed as a command line
         # argument.
-        self.config = SaltLintConfig(dict(c=self.fp.name))
+        self.config = Configuration(dict(c=self.fp.name))
 
     def tearDown(self):
         # Close the temporary configuration file.
diff --git a/tests/unit/TestFileExtension.py b/tests/unit/TestFileExtension.py
index 667054c..2e985c0 100644
--- a/tests/unit/TestFileExtension.py
+++ b/tests/unit/TestFileExtension.py
@@ -1,10 +1,11 @@
 # -*- coding: utf-8 -*-
-# Copyright (c) 2019 Warpnet B.V.
+# Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.config import SaltLintConfig
-from saltlint.linter import Runner, RulesCollection
+from saltlint.config import Configuration
+from saltlint.linter.runner import Runner
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.FileExtensionRule import FileExtensionRule
 
 
@@ -16,11 +17,11 @@ class TestLineTooLongRule(unittest.TestCase):
 
     def test_file_positive(self):
         path = 'tests/test-extension-success.sls'
-        runner = Runner(self.collection, path, SaltLintConfig())
+        runner = Runner(self.collection, path, Configuration())
         self.assertEqual([], runner.run())
 
     def test_file_negative(self):
         path = 'tests/test-extension-failure'
-        runner = Runner(self.collection, path, SaltLintConfig())
+        runner = Runner(self.collection, path, Configuration())
         errors = runner.run()
         self.assertEqual(1, len(errors))
diff --git a/tests/unit/TestFileModeLeadingZeroRule.py b/tests/unit/TestFileModeLeadingZeroRule.py
index 30156bf..7f842c8 100644
--- a/tests/unit/TestFileModeLeadingZeroRule.py
+++ b/tests/unit/TestFileModeLeadingZeroRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.FileModeLeadingZeroRule import FileModeLeadingZeroRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestFileModeQuotationRule.py b/tests/unit/TestFileModeQuotationRule.py
index 13fcff1..b2d2088 100644
--- a/tests/unit/TestFileModeQuotationRule.py
+++ b/tests/unit/TestFileModeQuotationRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.FileModeQuotationRule import FileModeQuotationRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestJinjaCommentHasSpaces.py b/tests/unit/TestJinjaCommentHasSpaces.py
index d8dae99..60796fe 100644
--- a/tests/unit/TestJinjaCommentHasSpaces.py
+++ b/tests/unit/TestJinjaCommentHasSpaces.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.JinjaCommentHasSpacesRule import JinjaCommentHasSpacesRule
 from tests import RunFromText
 
@@ -18,7 +18,7 @@ BAD_COMMENT_LINE = '''
 {#-set example='bad'+#}
 '''
 
-class TestLineTooLongRule(unittest.TestCase):
+class TestJinjaCommentHasSpaces(unittest.TestCase):
     collection = RulesCollection()
 
     def setUp(self):
diff --git a/tests/unit/TestJinjaPillarGrainsGetFormatRule.py b/tests/unit/TestJinjaPillarGrainsGetFormatRule.py
index 6293f3b..ec370a2 100644
--- a/tests/unit/TestJinjaPillarGrainsGetFormatRule.py
+++ b/tests/unit/TestJinjaPillarGrainsGetFormatRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.JinjaPillarGrainsGetFormatRule import JinjaPillarGrainsGetFormatRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestJinjaStatementHasSpaces.py b/tests/unit/TestJinjaStatementHasSpaces.py
index 9e965af..947a876 100644
--- a/tests/unit/TestJinjaStatementHasSpaces.py
+++ b/tests/unit/TestJinjaStatementHasSpaces.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.JinjaStatementHasSpacesRule import JinjaStatementHasSpacesRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestJinjaVariableHasSpaces.py b/tests/unit/TestJinjaVariableHasSpaces.py
index 1fd0448..301cb8d 100644
--- a/tests/unit/TestJinjaVariableHasSpaces.py
+++ b/tests/unit/TestJinjaVariableHasSpaces.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.JinjaVariableHasSpacesRule import JinjaVariableHasSpacesRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestLineTooLong.py b/tests/unit/TestLineTooLong.py
index 07b6169..c1f33c2 100644
--- a/tests/unit/TestLineTooLong.py
+++ b/tests/unit/TestLineTooLong.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.LineTooLongRule import LineTooLongRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestNoIrregularSpacesRule.py b/tests/unit/TestNoIrregularSpacesRule.py
index 320ce8f..f8133b4 100644
--- a/tests/unit/TestNoIrregularSpacesRule.py
+++ b/tests/unit/TestNoIrregularSpacesRule.py
@@ -1,8 +1,9 @@
 # -*- coding: utf-8 -*-
+# Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.NoIrregularSpacesRule import NoIrregularSpacesRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestNoTabsRule.py b/tests/unit/TestNoTabsRule.py
index ba336ff..3b82101 100644
--- a/tests/unit/TestNoTabsRule.py
+++ b/tests/unit/TestNoTabsRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.NoTabsRule import NoTabsRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestRunner.py b/tests/unit/TestRunner.py
index 122e1bd..b1e8e02 100644
--- a/tests/unit/TestRunner.py
+++ b/tests/unit/TestRunner.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright (c) 2019 Warpnet B.V.
+# Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
diff --git a/tests/unit/TestSkipRule.py b/tests/unit/TestSkipRule.py
index 269dc67..840c19e 100644
--- a/tests/unit/TestSkipRule.py
+++ b/tests/unit/TestSkipRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.LineTooLongRule import LineTooLongRule
 from saltlint.rules.JinjaVariableHasSpacesRule import JinjaVariableHasSpacesRule
 from tests import RunFromText
diff --git a/tests/unit/TestTrailingWhitespaceRule.py b/tests/unit/TestTrailingWhitespaceRule.py
index 6f54abf..d33cccd 100644
--- a/tests/unit/TestTrailingWhitespaceRule.py
+++ b/tests/unit/TestTrailingWhitespaceRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.TrailingWhitespaceRule import TrailingWhitespaceRule
 from tests import RunFromText
 
diff --git a/tests/unit/TestYamlHasOctalValueRule.py b/tests/unit/TestYamlHasOctalValueRule.py
index 46b0495..e03fc44 100644
--- a/tests/unit/TestYamlHasOctalValueRule.py
+++ b/tests/unit/TestYamlHasOctalValueRule.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 # Copyright (c) 2013-2018 Will Thames <will@thames.id.au>
 # Copyright (c) 2018 Ansible by Red Hat
-# Modified work Copyright (c) 2019 Warpnet B.V.
+# Modified work Copyright (c) 2020 Warpnet B.V.
 
 import unittest
 
-from saltlint.linter import RulesCollection
+from saltlint.linter.collection import RulesCollection
 from saltlint.rules.YamlHasOctalValueRule import YamlHasOctalValueRule
 from tests import RunFromText
 
@@ -28,6 +28,12 @@ testdirectory02:
 apache_disable_default_site:
   apache_site.disabled:
     - name: 000-default
+
+# MAC addresses shouldn't be matched, for more information see:
+# https://github.com/warpnet/salt-lint/issues/202
+infoblox_remove_record:
+  infoblox_host_record.absent:
+    - mac: 4c:f2:d3:1b:2e:05
 '''
 
 BAD_NUMBER_STATE = '''