1#!/usr/bin/env python3
2
3# Copyright (c) 2018,2020 Intel Corporation
4# Copyright (c) 2022 Nordic Semiconductor ASA
5# SPDX-License-Identifier: Apache-2.0
6
7import argparse
8import collections
9from email.utils import parseaddr
10import logging
11import os
12from pathlib import Path
13import re
14import subprocess
15import sys
16import tempfile
17import traceback
18import shlex
19import shutil
20import textwrap
21
22from yamllint import config, linter
23
24from junitparser import TestCase, TestSuite, JUnitXml, Skipped, Error, Failure
25import magic
26
27from west.manifest import Manifest
28from west.manifest import ManifestProject
29
30sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
31from get_maintainer import Maintainers, MaintainersError
32
33logger = None
34
35def git(*args, cwd=None, ignore_non_zero=False):
36    # Helper for running a Git command. Returns the rstrip()ed stdout output.
37    # Called like git("diff"). Exits with SystemError (raised by sys.exit()) on
38    # errors if 'ignore_non_zero' is set to False (default: False). 'cwd' is the
39    # working directory to use (default: current directory).
40
41    git_cmd = ("git",) + args
42    try:
43        cp = subprocess.run(git_cmd, capture_output=True, cwd=cwd)
44    except OSError as e:
45        err(f"failed to run '{cmd2str(git_cmd)}': {e}")
46
47    if not ignore_non_zero and (cp.returncode or cp.stderr):
48        err(f"'{cmd2str(git_cmd)}' exited with status {cp.returncode} and/or "
49            f"wrote to stderr.\n"
50            f"==stdout==\n"
51            f"{cp.stdout.decode('utf-8')}\n"
52            f"==stderr==\n"
53            f"{cp.stderr.decode('utf-8')}\n")
54
55    return cp.stdout.decode("utf-8").rstrip()
56
57def get_shas(refspec):
58    """
59    Returns the list of Git SHAs for 'refspec'.
60
61    :param refspec:
62    :return:
63    """
64    return git('rev-list',
65               f'--max-count={-1 if "." in refspec else 1}', refspec).split()
66
67def get_files(filter=None, paths=None):
68    filter_arg = (f'--diff-filter={filter}',) if filter else ()
69    paths_arg = ('--', *paths) if paths else ()
70    out = git('diff', '--name-only', *filter_arg, COMMIT_RANGE, *paths_arg)
71    files = out.splitlines()
72    for file in list(files):
73        if not os.path.isfile(os.path.join(GIT_TOP, file)):
74            # Drop submodule directories from the list.
75            files.remove(file)
76    return files
77
78class FmtdFailure(Failure):
79
80    def __init__(self, severity, title, file, line=None, col=None, desc=""):
81        self.severity = severity
82        self.title = title
83        self.file = file
84        self.line = line
85        self.col = col
86        self.desc = desc
87        description = f':{desc}' if desc else ''
88        msg_body = desc or title
89
90        txt = f'\n{title}{description}\nFile:{file}' + \
91              (f'\nLine:{line}' if line else '') + \
92              (f'\nColumn:{col}' if col else '')
93        msg = f'{file}' + (f':{line}' if line else '') + f' {msg_body}'
94        typ = severity.lower()
95
96        super().__init__(msg, typ)
97
98        self.text = txt
99
100
101class ComplianceTest:
102    """
103    Base class for tests. Inheriting classes should have a run() method and set
104    these class variables:
105
106    name:
107      Test name
108
109    doc:
110      Link to documentation related to what's being tested
111
112    path_hint:
113      The path the test runs itself in. This is just informative and used in
114      the message that gets printed when running the test.
115
116      There are two magic strings that can be used instead of a path:
117      - The magic string "<zephyr-base>" can be used to refer to the
118      environment variable ZEPHYR_BASE or, when missing, the calculated base of
119      the zephyr tree
120      - The magic string "<git-top>" refers to the top-level repository
121      directory. This avoids running 'git' to find the top-level directory
122      before main() runs (class variable assignments run when the 'class ...'
123      statement runs). That avoids swallowing errors, because main() reports
124      them to GitHub
125    """
126    def __init__(self):
127        self.case = TestCase(type(self).name, "Guidelines")
128        # This is necessary because Failure can be subclassed, but since it is
129        # always restored form the element tree, the subclass is lost upon
130        # restoring
131        self.fmtd_failures = []
132
133    def _result(self, res, text):
134        res.text = text.rstrip()
135        self.case.result += [res]
136
137    def error(self, text, msg=None, type_="error"):
138        """
139        Signals a problem with running the test, with message 'msg'.
140
141        Raises an exception internally, so you do not need to put a 'return'
142        after error().
143        """
144        err = Error(msg or f'{type(self).name} error', type_)
145        self._result(err, text)
146
147        raise EndTest
148
149    def skip(self, text, msg=None, type_="skip"):
150        """
151        Signals that the test should be skipped, with message 'msg'.
152
153        Raises an exception internally, so you do not need to put a 'return'
154        after skip().
155        """
156        skpd = Skipped(msg or f'{type(self).name} skipped', type_)
157        self._result(skpd, text)
158
159        raise EndTest
160
161    def failure(self, text, msg=None, type_="failure"):
162        """
163        Signals that the test failed, with message 'msg'. Can be called many
164        times within the same test to report multiple failures.
165        """
166        fail = Failure(msg or f'{type(self).name} issues', type_)
167        self._result(fail, text)
168
169    def fmtd_failure(self, severity, title, file, line=None, col=None, desc=""):
170        """
171        Signals that the test failed, and store the information in a formatted
172        standardized manner. Can be called many times within the same test to
173        report multiple failures.
174        """
175        fail = FmtdFailure(severity, title, file, line, col, desc)
176        self._result(fail, fail.text)
177        self.fmtd_failures.append(fail)
178
179
180class EndTest(Exception):
181    """
182    Raised by ComplianceTest.error()/skip() to end the test.
183
184    Tests can raise EndTest themselves to immediately end the test, e.g. from
185    within a nested function call.
186    """
187
188
189class CheckPatch(ComplianceTest):
190    """
191    Runs checkpatch and reports found issues
192
193    """
194    name = "Checkpatch"
195    doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#coding-style for more details."
196    path_hint = "<git-top>"
197
198    def run(self):
199        checkpatch = os.path.join(ZEPHYR_BASE, 'scripts', 'checkpatch.pl')
200        if not os.path.exists(checkpatch):
201            self.skip(f'{checkpatch} not found')
202
203        diff = subprocess.Popen(('git', 'diff', COMMIT_RANGE),
204                                stdout=subprocess.PIPE,
205                                cwd=GIT_TOP)
206        try:
207            subprocess.run((checkpatch, '--mailback', '--no-tree', '-'),
208                           check=True,
209                           stdin=diff.stdout,
210                           stdout=subprocess.PIPE,
211                           stderr=subprocess.STDOUT,
212                           shell=True, cwd=GIT_TOP)
213
214        except subprocess.CalledProcessError as ex:
215            output = ex.output.decode("utf-8")
216            regex = r'^\s*\S+:(\d+):\s*(ERROR|WARNING):(.+?):(.+)(?:\n|\r\n?)+' \
217                    r'^\s*#(\d+):\s*FILE:\s*(.+):(\d+):'
218
219            matches = re.findall(regex, output, re.MULTILINE)
220            for m in matches:
221                self.fmtd_failure(m[1].lower(), m[2], m[5], m[6], col=None,
222                        desc=m[3])
223
224            # If the regex has not matched add the whole output as a failure
225            if len(matches) == 0:
226                self.failure(output)
227
228
229class DevicetreeBindingsCheck(ComplianceTest):
230    """
231    Checks if we are introducing any unwanted properties in Devicetree Bindings.
232    """
233    name = "DevicetreeBindings"
234    doc = "See https://docs.zephyrproject.org/latest/build/dts/bindings.html for more details."
235    path_hint = "<zephyr-base>"
236
237    def run(self, full=True):
238        dts_bindings = self.parse_dt_bindings()
239
240        for dts_binding in dts_bindings:
241            self.required_false_check(dts_binding)
242
243    def parse_dt_bindings(self):
244        """
245        Returns a list of dts/bindings/**/*.yaml files
246        """
247
248        dt_bindings = []
249        for file_name in get_files(filter="d"):
250            if 'dts/bindings/' in file_name and file_name.endswith('.yaml'):
251                dt_bindings.append(file_name)
252
253        return dt_bindings
254
255    def required_false_check(self, dts_binding):
256        with open(dts_binding) as file:
257            line_number = 0
258            for line in file:
259                line_number += 1
260                if 'required: false' in line:
261                    self.fmtd_failure(
262                        'warning', 'Devicetree Bindings', dts_binding,
263                        line_number, col=None,
264                        desc="'required: false' is redundant, please remove")
265
266
267class KconfigCheck(ComplianceTest):
268    """
269    Checks is we are introducing any new warnings/errors with Kconfig,
270    for example using undefined Kconfig variables.
271    """
272    name = "Kconfig"
273    doc = "See https://docs.zephyrproject.org/latest/build/kconfig/tips.html for more details."
274    path_hint = "<zephyr-base>"
275
276    def run(self, full=True, no_modules=False):
277        self.no_modules = no_modules
278
279        kconf = self.parse_kconfig()
280
281        self.check_top_menu_not_too_long(kconf)
282        self.check_no_pointless_menuconfigs(kconf)
283        self.check_no_undef_within_kconfig(kconf)
284        self.check_no_redefined_in_defconfig(kconf)
285        self.check_no_enable_in_boolean_prompt(kconf)
286        if full:
287            self.check_no_undef_outside_kconfig(kconf)
288
289    def get_modules(self, modules_file, settings_file):
290        """
291        Get a list of modules and put them in a file that is parsed by
292        Kconfig
293
294        This is needed to complete Kconfig sanity tests.
295
296        """
297        if self.no_modules:
298            with open(modules_file, 'w') as fp_module_file:
299                fp_module_file.write("# Empty\n")
300            return
301
302        # Invoke the script directly using the Python executable since this is
303        # not a module nor a pip-installed Python utility
304        zephyr_module_path = os.path.join(ZEPHYR_BASE, "scripts",
305                                          "zephyr_module.py")
306        cmd = [sys.executable, zephyr_module_path,
307               '--kconfig-out', modules_file, '--settings-out', settings_file]
308        try:
309            subprocess.run(cmd, check=True, stdout=subprocess.PIPE,
310                           stderr=subprocess.STDOUT)
311        except subprocess.CalledProcessError as ex:
312            self.error(ex.output.decode("utf-8"))
313
314        modules_dir = ZEPHYR_BASE + '/modules'
315        modules = [name for name in os.listdir(modules_dir) if
316                   os.path.exists(os.path.join(modules_dir, name, 'Kconfig'))]
317
318        with open(modules_file, 'r') as fp_module_file:
319            content = fp_module_file.read()
320
321        with open(modules_file, 'w') as fp_module_file:
322            for module in modules:
323                fp_module_file.write("ZEPHYR_{}_KCONFIG = {}\n".format(
324                    re.sub('[^a-zA-Z0-9]', '_', module).upper(),
325                    modules_dir + '/' + module + '/Kconfig'
326                ))
327            fp_module_file.write(content)
328
329    def get_kconfig_dts(self, kconfig_dts_file, settings_file):
330        """
331        Generate the Kconfig.dts using dts/bindings as the source.
332
333        This is needed to complete Kconfig compliance tests.
334
335        """
336        # Invoke the script directly using the Python executable since this is
337        # not a module nor a pip-installed Python utility
338        zephyr_drv_kconfig_path = os.path.join(ZEPHYR_BASE, "scripts", "dts",
339                                               "gen_driver_kconfig_dts.py")
340        binding_paths = []
341        binding_paths.append(os.path.join(ZEPHYR_BASE, "dts", "bindings"))
342
343        if os.path.exists(settings_file):
344            with open(settings_file, 'r') as fp_setting_file:
345                content = fp_setting_file.read()
346
347            lines = content.strip().split('\n')
348            for line in lines:
349                if line.startswith('"DTS_ROOT":'):
350                    _, dts_root_path = line.split(":")
351                    binding_paths.append(os.path.join(dts_root_path.strip('"'), "dts", "bindings"))
352
353        cmd = [sys.executable, zephyr_drv_kconfig_path,
354               '--kconfig-out', kconfig_dts_file, '--bindings-dirs']
355        for binding_path in binding_paths:
356            cmd.append(binding_path)
357        try:
358            subprocess.run(cmd, check=True, stdout=subprocess.PIPE,
359                           stderr=subprocess.STDOUT)
360        except subprocess.CalledProcessError as ex:
361            self.error(ex.output.decode("utf-8"))
362
363
364    def parse_kconfig(self):
365        """
366        Returns a kconfiglib.Kconfig object for the Kconfig files. We reuse
367        this object for all tests to avoid having to reparse for each test.
368        """
369        # Put the Kconfiglib path first to make sure no local Kconfiglib version is
370        # used
371        kconfig_path = os.path.join(ZEPHYR_BASE, "scripts", "kconfig")
372        if not os.path.exists(kconfig_path):
373            self.error(kconfig_path + " not found")
374
375        kconfiglib_dir = tempfile.mkdtemp(prefix="kconfiglib_")
376
377        sys.path.insert(0, kconfig_path)
378        # Import globally so that e.g. kconfiglib.Symbol can be referenced in
379        # tests
380        global kconfiglib
381        import kconfiglib
382
383        # Look up Kconfig files relative to ZEPHYR_BASE
384        os.environ["srctree"] = ZEPHYR_BASE
385
386        # Parse the entire Kconfig tree, to make sure we see all symbols
387        os.environ["SOC_DIR"] = "soc/"
388        os.environ["ARCH_DIR"] = "arch/"
389        os.environ["BOARD_DIR"] = "boards/*/*"
390        os.environ["ARCH"] = "*"
391        os.environ["KCONFIG_BINARY_DIR"] = kconfiglib_dir
392        os.environ['DEVICETREE_CONF'] = "dummy"
393        os.environ['TOOLCHAIN_HAS_NEWLIB'] = "y"
394
395        # Older name for DEVICETREE_CONF, for compatibility with older Zephyr
396        # versions that don't have the renaming
397        os.environ["GENERATED_DTS_BOARD_CONF"] = "dummy"
398
399        # For multi repo support
400        self.get_modules(os.path.join(kconfiglib_dir, "Kconfig.modules"),
401                         os.path.join(kconfiglib_dir, "settings_file.txt"))
402        # For Kconfig.dts support
403        self.get_kconfig_dts(os.path.join(kconfiglib_dir, "Kconfig.dts"),
404                             os.path.join(kconfiglib_dir, "settings_file.txt"))
405
406        # Tells Kconfiglib to generate warnings for all references to undefined
407        # symbols within Kconfig files
408        os.environ["KCONFIG_WARN_UNDEF"] = "y"
409
410        try:
411            # Note this will both print warnings to stderr _and_ return
412            # them: so some warnings might get printed
413            # twice. "warn_to_stderr=False" could unfortunately cause
414            # some (other) warnings to never be printed.
415            return kconfiglib.Kconfig()
416        except kconfiglib.KconfigError as e:
417            self.failure(str(e))
418            raise EndTest
419        finally:
420            # Clean up the temporary directory
421            shutil.rmtree(kconfiglib_dir)
422
423    def get_defined_syms(self, kconf):
424        # Returns a set() with the names of all defined Kconfig symbols (with no
425        # 'CONFIG_' prefix). This is complicated by samples and tests defining
426        # their own Kconfig trees. For those, just grep for 'config FOO' to find
427        # definitions. Doing it "properly" with Kconfiglib is still useful for
428        # the main tree, because some symbols are defined using preprocessor
429        # macros.
430
431        # Warning: Needs to work with both --perl-regexp and the 're' module.
432        # (?:...) is a non-capturing group.
433        regex = r"^\s*(?:menu)?config\s*([A-Z0-9_]+)\s*(?:#|$)"
434
435        # Grep samples/ and tests/ for symbol definitions
436        grep_stdout = git("grep", "-I", "-h", "--perl-regexp", regex, "--",
437                          ":samples", ":tests", cwd=ZEPHYR_BASE)
438
439        # Generate combined list of configs and choices from the main Kconfig tree.
440        kconf_syms = kconf.unique_defined_syms + kconf.unique_choices
441
442        # Symbols from the main Kconfig tree + grepped definitions from samples
443        # and tests
444        return set([sym.name for sym in kconf_syms]
445                   + re.findall(regex, grep_stdout, re.MULTILINE))
446
447
448    def check_top_menu_not_too_long(self, kconf):
449        """
450        Checks that there aren't too many items in the top-level menu (which
451        might be a sign that stuff accidentally got added there)
452        """
453        max_top_items = 50
454
455        n_top_items = 0
456        node = kconf.top_node.list
457        while node:
458            # Only count items with prompts. Other items will never be
459            # shown in the menuconfig (outside show-all mode).
460            if node.prompt:
461                n_top_items += 1
462            node = node.next
463
464        if n_top_items > max_top_items:
465            self.failure(f"""
466Expected no more than {max_top_items} potentially visible items (items with
467prompts) in the top-level Kconfig menu, found {n_top_items} items. If you're
468deliberately adding new entries, then bump the 'max_top_items' variable in
469{__file__}.""")
470
471    def check_no_redefined_in_defconfig(self, kconf):
472        # Checks that no symbols are (re)defined in defconfigs.
473
474        for node in kconf.node_iter():
475            # 'kconfiglib' is global
476            # pylint: disable=undefined-variable
477            if "defconfig" in node.filename and (node.prompt or node.help):
478                name = (node.item.name if node.item not in
479                        (kconfiglib.MENU, kconfiglib.COMMENT) else str(node))
480                self.failure(f"""
481Kconfig node '{name}' found with prompt or help in {node.filename}.
482Options must not be defined in defconfig files.
483""")
484                continue
485
486    def check_no_enable_in_boolean_prompt(self, kconf):
487        # Checks that boolean's prompt does not start with "Enable...".
488
489        for node in kconf.node_iter():
490            # skip Kconfig nodes not in-tree (will present an absolute path)
491            if os.path.isabs(node.filename):
492                continue
493
494            # 'kconfiglib' is global
495            # pylint: disable=undefined-variable
496
497            # only process boolean symbols with a prompt
498            if (not isinstance(node.item, kconfiglib.Symbol) or
499                node.item.type != kconfiglib.BOOL or
500                not node.prompt or
501                not node.prompt[0]):
502                continue
503
504            if re.match(r"^[Ee]nable.*", node.prompt[0]):
505                self.failure(f"""
506Boolean option '{node.item.name}' prompt must not start with 'Enable...'. Please
507check Kconfig guidelines.
508""")
509                continue
510
511    def check_no_pointless_menuconfigs(self, kconf):
512        # Checks that there are no pointless 'menuconfig' symbols without
513        # children in the Kconfig files
514
515        bad_mconfs = []
516        for node in kconf.node_iter():
517            # 'kconfiglib' is global
518            # pylint: disable=undefined-variable
519
520            # Avoid flagging empty regular menus and choices, in case people do
521            # something with 'osource' (could happen for 'menuconfig' symbols
522            # too, though it's less likely)
523            if node.is_menuconfig and not node.list and \
524               isinstance(node.item, kconfiglib.Symbol):
525
526                bad_mconfs.append(node)
527
528        if bad_mconfs:
529            self.failure("""\
530Found pointless 'menuconfig' symbols without children. Use regular 'config'
531symbols instead. See
532https://docs.zephyrproject.org/latest/build/kconfig/tips.html#menuconfig-symbols.
533
534""" + "\n".join(f"{node.item.name:35} {node.filename}:{node.linenr}"
535                for node in bad_mconfs))
536
537    def check_no_undef_within_kconfig(self, kconf):
538        """
539        Checks that there are no references to undefined Kconfig symbols within
540        the Kconfig files
541        """
542        undef_ref_warnings = "\n\n\n".join(warning for warning in kconf.warnings
543                                           if "undefined symbol" in warning)
544
545        if undef_ref_warnings:
546            self.failure(f"Undefined Kconfig symbols:\n\n {undef_ref_warnings}")
547
548    def check_no_undef_outside_kconfig(self, kconf):
549        """
550        Checks that there are no references to undefined Kconfig symbols
551        outside Kconfig files (any CONFIG_FOO where no FOO symbol exists)
552        """
553        # Grep for symbol references.
554        #
555        # Example output line for a reference to CONFIG_FOO at line 17 of
556        # foo/bar.c:
557        #
558        #   foo/bar.c<null>17<null>#ifdef CONFIG_FOO
559        #
560        # 'git grep --only-matching' would get rid of the surrounding context
561        # ('#ifdef '), but it was added fairly recently (second half of 2018),
562        # so we extract the references from each line ourselves instead.
563        #
564        # The regex uses word boundaries (\b) to isolate the reference, and
565        # negative lookahead to automatically whitelist the following:
566        #
567        #  - ##, for token pasting (CONFIG_FOO_##X)
568        #
569        #  - $, e.g. for CMake variable expansion (CONFIG_FOO_${VAR})
570        #
571        #  - @, e.g. for CMakes's configure_file() (CONFIG_FOO_@VAR@)
572        #
573        #  - {, e.g. for Python scripts ("CONFIG_FOO_{}_BAR".format(...)")
574        #
575        #  - *, meant for comments like '#endif /* CONFIG_FOO_* */
576
577        defined_syms = self.get_defined_syms(kconf)
578
579        # Maps each undefined symbol to a list <filename>:<linenr> strings
580        undef_to_locs = collections.defaultdict(list)
581
582        # Warning: Needs to work with both --perl-regexp and the 're' module
583        regex = r"\bCONFIG_[A-Z0-9_]+\b(?!\s*##|[$@{*])"
584
585        # Skip doc/releases, which often references removed symbols
586        grep_stdout = git("grep", "--line-number", "-I", "--null",
587                          "--perl-regexp", regex, "--", ":!/doc/releases",
588                          cwd=Path(GIT_TOP))
589
590        # splitlines() supports various line terminators
591        for grep_line in grep_stdout.splitlines():
592            path, lineno, line = grep_line.split("\0")
593
594            # Extract symbol references (might be more than one) within the
595            # line
596            for sym_name in re.findall(regex, line):
597                sym_name = sym_name[7:]  # Strip CONFIG_
598                if sym_name not in defined_syms and \
599                   sym_name not in self.UNDEF_KCONFIG_WHITELIST:
600
601                    undef_to_locs[sym_name].append(f"{path}:{lineno}")
602
603        if not undef_to_locs:
604            return
605
606        # String that describes all referenced but undefined Kconfig symbols,
607        # in alphabetical order, along with the locations where they're
608        # referenced. Example:
609        #
610        #   CONFIG_ALSO_MISSING    arch/xtensa/core/fatal.c:273
611        #   CONFIG_MISSING         arch/xtensa/core/fatal.c:264, subsys/fb/cfb.c:20
612        undef_desc = "\n".join(f"CONFIG_{sym_name:35} {', '.join(locs)}"
613            for sym_name, locs in sorted(undef_to_locs.items()))
614
615        self.failure(f"""
616Found references to undefined Kconfig symbols. If any of these are false
617positives, then add them to UNDEF_KCONFIG_WHITELIST in {__file__}.
618
619If the reference is for a comment like /* CONFIG_FOO_* */ (or
620/* CONFIG_FOO_*_... */), then please use exactly that form (with the '*'). The
621CI check knows not to flag it.
622
623More generally, a reference followed by $, @, {{, *, or ## will never be
624flagged.
625
626{undef_desc}""")
627
628    # Many of these are symbols used as examples. Note that the list is sorted
629    # alphabetically, and skips the CONFIG_ prefix.
630    UNDEF_KCONFIG_WHITELIST = {
631        "ALSO_MISSING",
632        "APP_LINK_WITH_",
633        "APP_LOG_LEVEL", # Application log level is not detected correctly as
634                         # the option is defined using a template, so it can't
635                         # be grepped
636        "ARMCLANG_STD_LIBC",  # The ARMCLANG_STD_LIBC is defined in the
637                              # toolchain Kconfig which is sourced based on
638                              # Zephyr toolchain variant and therefore not
639                              # visible to compliance.
640        "BOOT_ENCRYPTION_KEY_FILE", # Used in sysbuild
641        "BOOT_ENCRYPT_IMAGE", # Used in sysbuild
642        "BINDESC_", # Used in documentation as a prefix
643        "BOOT_UPGRADE_ONLY", # Used in example adjusting MCUboot config, but
644                             # symbol is defined in MCUboot itself.
645        "BOOT_SERIAL_BOOT_MODE",     # Used in (sysbuild-based) test/
646                                     # documentation
647        "BOOT_SERIAL_CDC_ACM",       # Used in (sysbuild-based) test
648        "BOOT_SERIAL_ENTRANCE_GPIO", # Used in (sysbuild-based) test
649        "BOOT_SERIAL_IMG_GRP_HASH",  # Used in documentation
650        "BOOT_SHARE_DATA",           # Used in Kconfig text
651        "BOOT_SHARE_DATA_BOOTINFO", # Used in (sysbuild-based) test
652        "BOOT_SHARE_BACKEND_RETENTION", # Used in Kconfig text
653        "BOOT_SIGNATURE_KEY_FILE",   # MCUboot setting used by sysbuild
654        "BOOT_SIGNATURE_TYPE_ECDSA_P256", # MCUboot setting used by sysbuild
655        "BOOT_SIGNATURE_TYPE_ED25519",    # MCUboot setting used by sysbuild
656        "BOOT_SIGNATURE_TYPE_NONE",       # MCUboot setting used by sysbuild
657        "BOOT_SIGNATURE_TYPE_RSA",        # MCUboot setting used by sysbuild
658        "BOOT_VALIDATE_SLOT0",       # Used in (sysbuild-based) test
659        "BOOT_WATCHDOG_FEED",        # Used in (sysbuild-based) test
660        "BTTESTER_LOG_LEVEL",  # Used in tests/bluetooth/tester
661        "BTTESTER_LOG_LEVEL_DBG",  # Used in tests/bluetooth/tester
662        "CDC_ACM_PORT_NAME_",
663        "CHRE",  # Optional module
664        "CHRE_LOG_LEVEL_DBG",  # Optional module
665        "CLOCK_STM32_SYSCLK_SRC_",
666        "CMU",
667        "COMPILER_RT_RTLIB",
668        "BT_6LOWPAN",  # Defined in Linux, mentioned in docs
669        "CMD_CACHE",  # Defined in U-Boot, mentioned in docs
670        "COUNTER_RTC_STM32_CLOCK_SRC",
671        "CRC",  # Used in TI CC13x2 / CC26x2 SDK comment
672        "DEEP_SLEEP",  # #defined by RV32M1 in ext/
673        "DESCRIPTION",
674        "ERR",
675        "ESP_DIF_LIBRARY",  # Referenced in CMake comment
676        "EXPERIMENTAL",
677        "FFT",  # Used as an example in cmake/extensions.cmake
678        "FLAG",  # Used as an example
679        "FOO",
680        "FOO_LOG_LEVEL",
681        "FOO_SETTING_1",
682        "FOO_SETTING_2",
683        "HEAP_MEM_POOL_ADD_SIZE_", # Used as an option matching prefix
684        "LSM6DSO_INT_PIN",
685        "LIBGCC_RTLIB",
686        "LLVM_USE_LD",   # Both LLVM_USE_* are in cmake/toolchain/llvm/Kconfig
687        "LLVM_USE_LLD",  # which are only included if LLVM is selected but
688                         # not other toolchains. Compliance check would complain,
689                         # for example, if you are using GCC.
690        "MCUBOOT_LOG_LEVEL_WRN",        # Used in example adjusting MCUboot
691                                        # config,
692        "MCUBOOT_LOG_LEVEL_INF",
693        "MCUBOOT_DOWNGRADE_PREVENTION", # but symbols are defined in MCUboot
694                                        # itself.
695        "MCUBOOT_ACTION_HOOKS",     # Used in (sysbuild-based) test
696        "MCUBOOT_CLEANUP_ARM_CORE", # Used in (sysbuild-based) test
697        "MCUBOOT_SERIAL",           # Used in (sysbuild-based) test/
698                                    # documentation
699        "MCUMGR_GRP_EXAMPLE", # Used in documentation
700        "MCUMGR_GRP_EXAMPLE_LOG_LEVEL", # Used in documentation
701        "MCUMGR_GRP_EXAMPLE_OTHER_HOOK", # Used in documentation
702        "MISSING",
703        "MODULES",
704        "MYFEATURE",
705        "MY_DRIVER_0",
706        "NORMAL_SLEEP",  # #defined by RV32M1 in ext/
707        "OPT",
708        "OPT_0",
709        "PEDO_THS_MIN",
710        "REG1",
711        "REG2",
712        "RIMAGE_SIGNING_SCHEMA",  # Optional module
713        "SAMPLE_MODULE_LOG_LEVEL",  # Used as an example in samples/subsys/logging
714        "SAMPLE_MODULE_LOG_LEVEL_DBG",  # Used in tests/subsys/logging/log_api
715        "LOG_BACKEND_MOCK_OUTPUT_DEFAULT", #Referenced in tests/subsys/logging/log_syst
716        "LOG_BACKEND_MOCK_OUTPUT_SYST", #Referenced in testcase.yaml of log_syst test
717        "SEL",
718        "SHIFT",
719        "SOC_WATCH",  # Issue 13749
720        "SOME_BOOL",
721        "SOME_INT",
722        "SOME_OTHER_BOOL",
723        "SOME_STRING",
724        "SRAM2",  # Referenced in a comment in samples/application_development
725        "STACK_SIZE",  # Used as an example in the Kconfig docs
726        "STD_CPP",  # Referenced in CMake comment
727        "TAGOIO_HTTP_POST_LOG_LEVEL",  # Used as in samples/net/cloud/tagoio
728        "TEST1",
729        "TOOLCHAIN_ARCMWDT_SUPPORTS_THREAD_LOCAL_STORAGE", # The symbol is defined in the toolchain
730                                                    # Kconfig which is sourced based on Zephyr
731                                                    # toolchain variant and therefore not visible
732                                                    # to compliance.
733        "TYPE_BOOLEAN",
734        "USB_CONSOLE",
735        "USE_STDC_",
736        "WHATEVER",
737        "EXTRA_FIRMWARE_DIR", # Linux, in boards/xtensa/intel_adsp_cavs25/doc
738        "HUGETLBFS",          # Linux, in boards/xtensa/intel_adsp_cavs25/doc
739        "MODVERSIONS",        # Linux, in boards/xtensa/intel_adsp_cavs25/doc
740        "SECURITY_LOADPIN",   # Linux, in boards/xtensa/intel_adsp_cavs25/doc
741        "ZEPHYR_TRY_MASS_ERASE", # MCUBoot setting described in sysbuild
742                                 # documentation
743        "ZTEST_FAIL_TEST_",  # regex in tests/ztest/fail/CMakeLists.txt
744    }
745
746
747class KconfigBasicCheck(KconfigCheck):
748    """
749    Checks if we are introducing any new warnings/errors with Kconfig,
750    for example using undefined Kconfig variables.
751    This runs the basic Kconfig test, which is checking only for undefined
752    references inside the Kconfig tree.
753    """
754    name = "KconfigBasic"
755    doc = "See https://docs.zephyrproject.org/latest/build/kconfig/tips.html for more details."
756    path_hint = "<zephyr-base>"
757
758    def run(self):
759        super().run(full=False)
760
761class KconfigBasicNoModulesCheck(KconfigCheck):
762    """
763    Checks if we are introducing any new warnings/errors with Kconfig when no
764    modules are available. Catches symbols used in the main repository but
765    defined only in a module.
766    """
767    name = "KconfigBasicNoModules"
768    doc = "See https://docs.zephyrproject.org/latest/build/kconfig/tips.html for more details."
769    path_hint = "<zephyr-base>"
770    def run(self):
771        super().run(full=False, no_modules=True)
772
773
774class Nits(ComplianceTest):
775    """
776    Checks various nits in added/modified files. Doesn't check stuff that's
777    already covered by e.g. checkpatch.pl and pylint.
778    """
779    name = "Nits"
780    doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#coding-style for more details."
781    path_hint = "<git-top>"
782
783    def run(self):
784        # Loop through added/modified files
785        for fname in get_files(filter="d"):
786            if "Kconfig" in fname:
787                self.check_kconfig_header(fname)
788                self.check_redundant_zephyr_source(fname)
789
790            if fname.startswith("dts/bindings/"):
791                self.check_redundant_document_separator(fname)
792
793            if fname.endswith((".c", ".conf", ".cpp", ".dts", ".overlay",
794                               ".h", ".ld", ".py", ".rst", ".txt", ".yaml",
795                               ".yml")) or \
796               "Kconfig" in fname or \
797               "defconfig" in fname or \
798               fname == "README":
799
800                self.check_source_file(fname)
801
802    def check_kconfig_header(self, fname):
803        # Checks for a spammy copy-pasted header format
804
805        with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f:
806            contents = f.read()
807
808        # 'Kconfig - yada yada' has a copy-pasted redundant filename at the
809        # top. This probably means all of the header was copy-pasted.
810        if re.match(r"\s*#\s*(K|k)config[\w.-]*\s*-", contents):
811            self.failure(f"""
812Please use this format for the header in '{fname}' (see
813https://docs.zephyrproject.org/latest/build/kconfig/tips.html#header-comments-and-other-nits):
814
815    # <Overview of symbols defined in the file, preferably in plain English>
816    (Blank line)
817    # Copyright (c) 2019 ...
818    # SPDX-License-Identifier: <License>
819    (Blank line)
820    (Kconfig definitions)
821
822Skip the "Kconfig - " part of the first line, since it's clear that the comment
823is about Kconfig from context. The "# Kconfig - " is what triggers this
824failure.
825""")
826
827    def check_redundant_zephyr_source(self, fname):
828        # Checks for 'source "$(ZEPHYR_BASE)/Kconfig[.zephyr]"', which can be
829        # be simplified to 'source "Kconfig[.zephyr]"'
830
831        with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f:
832            # Look for e.g. rsource as well, for completeness
833            match = re.search(
834                r'^\s*(?:o|r|or)?source\s*"\$\(?ZEPHYR_BASE\)?/(Kconfig(?:\.zephyr)?)"',
835                f.read(), re.MULTILINE)
836
837            if match:
838                self.failure("""
839Redundant 'source "$(ZEPHYR_BASE)/{0}" in '{1}'. Just do 'source "{0}"'
840instead. The $srctree environment variable already points to the Zephyr root,
841and all 'source's are relative to it.""".format(match.group(1), fname))
842
843    def check_redundant_document_separator(self, fname):
844        # Looks for redundant '...' document separators in bindings
845
846        with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f:
847            if re.search(r"^\.\.\.", f.read(), re.MULTILINE):
848                self.failure(f"""\
849Redundant '...' document separator in {fname}. Binding YAML files are never
850concatenated together, so no document separators are needed.""")
851
852    def check_source_file(self, fname):
853        # Generic nits related to various source files
854
855        with open(os.path.join(GIT_TOP, fname), encoding="utf-8") as f:
856            contents = f.read()
857
858        if not contents.endswith("\n"):
859            self.failure(f"Missing newline at end of '{fname}'. Check your text "
860                         f"editor settings.")
861
862        if contents.startswith("\n"):
863            self.failure(f"Please remove blank lines at start of '{fname}'")
864
865        if contents.endswith("\n\n"):
866            self.failure(f"Please remove blank lines at end of '{fname}'")
867
868
869class GitDiffCheck(ComplianceTest):
870    """
871    Checks for conflict markers or whitespace errors with git diff --check
872    """
873    name = "GitDiffCheck"
874    doc = "Git conflict markers and whitespace errors are not allowed in added changes"
875    path_hint = "<git-top>"
876
877    def run(self):
878        offending_lines = []
879        # Use regex to filter out unnecessay output
880        # Reason: `--check` is mutually exclusive with `--name-only` and `-s`
881        p = re.compile(r"\S+\: .*\.")
882
883        for shaidx in get_shas(COMMIT_RANGE):
884            # Ignore non-zero return status code
885            # Reason: `git diff --check` sets the return code to the number of offending lines
886            diff = git("diff", f"{shaidx}^!", "--check", ignore_non_zero=True)
887
888            lines = p.findall(diff)
889            lines = map(lambda x: f"{shaidx}: {x}", lines)
890            offending_lines.extend(lines)
891
892        if len(offending_lines) > 0:
893            self.failure("\n".join(offending_lines))
894
895
896class GitLint(ComplianceTest):
897    """
898    Runs gitlint on the commits and finds issues with style and syntax
899
900    """
901    name = "Gitlint"
902    doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#commit-guidelines for more details"
903    path_hint = "<git-top>"
904
905    def run(self):
906        # By default gitlint looks for .gitlint configuration only in
907        # the current directory
908        try:
909            subprocess.run('gitlint --commits ' + COMMIT_RANGE,
910                           check=True,
911                           stdout=subprocess.PIPE,
912                           stderr=subprocess.STDOUT,
913                           shell=True, cwd=GIT_TOP)
914
915        except subprocess.CalledProcessError as ex:
916            self.failure(ex.output.decode("utf-8"))
917
918
919class PyLint(ComplianceTest):
920    """
921    Runs pylint on all .py files, with a limited set of checks enabled. The
922    configuration is in the pylintrc file.
923    """
924    name = "Pylint"
925    doc = "See https://www.pylint.org/ for more details"
926    path_hint = "<git-top>"
927
928    def run(self):
929        # Path to pylint configuration file
930        pylintrc = os.path.abspath(os.path.join(os.path.dirname(__file__),
931                                                "pylintrc"))
932
933        # Path to additional pylint check scripts
934        check_script_dir = os.path.abspath(os.path.join(os.path.dirname(__file__),
935                                                        "../pylint/checkers"))
936
937        # List of files added/modified by the commit(s).
938        files = get_files(filter="d")
939
940        # Filter out everything but Python files. Keep filenames
941        # relative (to GIT_TOP) to stay farther from any command line
942        # limit.
943        py_files = filter_py(GIT_TOP, files)
944        if not py_files:
945            return
946
947        python_environment = os.environ.copy()
948        if "PYTHONPATH" in python_environment:
949            python_environment["PYTHONPATH"] = check_script_dir + ":" + \
950                                               python_environment["PYTHONPATH"]
951        else:
952            python_environment["PYTHONPATH"] = check_script_dir
953
954        pylintcmd = ["pylint", "--rcfile=" + pylintrc,
955                     "--load-plugins=argparse-checker"] + py_files
956        logger.info(cmd2str(pylintcmd))
957        try:
958            subprocess.run(pylintcmd,
959                           check=True,
960                           stdout=subprocess.PIPE,
961                           stderr=subprocess.STDOUT,
962                           cwd=GIT_TOP,
963                           env=python_environment)
964        except subprocess.CalledProcessError as ex:
965            output = ex.output.decode("utf-8")
966            regex = r'^\s*(\S+):(\d+):(\d+):\s*([A-Z]\d{4}):\s*(.*)$'
967
968            matches = re.findall(regex, output, re.MULTILINE)
969            for m in matches:
970                # https://pylint.pycqa.org/en/latest/user_guide/messages/messages_overview.html#
971                severity = 'unknown'
972                if m[3][0] in ('F', 'E'):
973                    severity = 'error'
974                elif m[3][0] in ('W','C', 'R', 'I'):
975                    severity = 'warning'
976                self.fmtd_failure(severity, m[3], m[0], m[1], col=m[2],
977                        desc=m[4])
978
979            # If the regex has not matched add the whole output as a failure
980            if len(matches) == 0:
981                self.failure(output)
982
983
984def filter_py(root, fnames):
985    # PyLint check helper. Returns all Python script filenames among the
986    # filenames in 'fnames', relative to directory 'root'.
987    #
988    # Uses the python-magic library, so that we can detect Python
989    # files that don't end in .py as well. python-magic is a frontend
990    # to libmagic, which is also used by 'file'.
991    return [fname for fname in fnames
992            if (fname.endswith(".py") or
993             magic.from_file(os.path.join(root, fname),
994                             mime=True) == "text/x-python")]
995
996
997class Identity(ComplianceTest):
998    """
999    Checks if Emails of author and signed-off messages are consistent.
1000    """
1001    name = "Identity"
1002    doc = "See https://docs.zephyrproject.org/latest/contribute/guidelines.html#commit-guidelines for more details"
1003    # git rev-list and git log don't depend on the current (sub)directory
1004    # unless explicited
1005    path_hint = "<git-top>"
1006
1007    def run(self):
1008        for shaidx in get_shas(COMMIT_RANGE):
1009            commit = git("log", "--decorate=short", "-n 1", shaidx)
1010            signed = []
1011            author = ""
1012            sha = ""
1013            parsed_addr = None
1014            for line in commit.split("\n"):
1015                match = re.search(r"^commit\s([^\s]*)", line)
1016                if match:
1017                    sha = match.group(1)
1018                match = re.search(r"^Author:\s(.*)", line)
1019                if match:
1020                    author = match.group(1)
1021                    parsed_addr = parseaddr(author)
1022                match = re.search(r"signed-off-by:\s(.*)", line, re.IGNORECASE)
1023                if match:
1024                    signed.append(match.group(1))
1025
1026            error1 = f"{sha}: author email ({author}) needs to match one of " \
1027                     f"the signed-off-by entries."
1028            error2 = f"{sha}: author email ({author}) does not follow the " \
1029                     f"syntax: First Last <email>."
1030            error3 = f"{sha}: author email ({author}) must be a real email " \
1031                     f"and cannot end in @users.noreply.github.com"
1032            failure = None
1033            if author not in signed:
1034                failure = error1
1035
1036            if not parsed_addr or len(parsed_addr[0].split(" ")) < 2:
1037                if not failure:
1038
1039                    failure = error2
1040                else:
1041                    failure = failure + "\n" + error2
1042            elif parsed_addr[1].endswith("@users.noreply.github.com"):
1043                failure = error3
1044
1045            if failure:
1046                self.failure(failure)
1047
1048
1049class BinaryFiles(ComplianceTest):
1050    """
1051    Check that the diff contains no binary files.
1052    """
1053    name = "BinaryFiles"
1054    doc = "No binary files allowed."
1055    path_hint = "<git-top>"
1056
1057    def run(self):
1058        BINARY_ALLOW_PATHS = ("doc/", "boards/", "samples/")
1059        # svg files are always detected as binary, see .gitattributes
1060        BINARY_ALLOW_EXT = (".jpg", ".jpeg", ".png", ".svg", ".webp")
1061
1062        for stat in git("diff", "--numstat", "--diff-filter=A",
1063                        COMMIT_RANGE).splitlines():
1064            added, deleted, fname = stat.split("\t")
1065            if added == "-" and deleted == "-":
1066                if (fname.startswith(BINARY_ALLOW_PATHS) and
1067                    fname.endswith(BINARY_ALLOW_EXT)):
1068                    continue
1069                self.failure(f"Binary file not allowed: {fname}")
1070
1071
1072class ImageSize(ComplianceTest):
1073    """
1074    Check that any added image is limited in size.
1075    """
1076    name = "ImageSize"
1077    doc = "Check the size of image files."
1078    path_hint = "<git-top>"
1079
1080    def run(self):
1081        SIZE_LIMIT = 250 << 10
1082        BOARD_SIZE_LIMIT = 100 << 10
1083
1084        for file in get_files(filter="d"):
1085            full_path = os.path.join(GIT_TOP, file)
1086            mime_type = magic.from_file(full_path, mime=True)
1087
1088            if not mime_type.startswith("image/"):
1089                continue
1090
1091            size = os.path.getsize(full_path)
1092
1093            limit = SIZE_LIMIT
1094            if file.startswith("boards/"):
1095                limit = BOARD_SIZE_LIMIT
1096
1097            if size > limit:
1098                self.failure(f"Image file too large: {file} reduce size to "
1099                             f"less than {limit >> 10}kB")
1100
1101
1102class MaintainersFormat(ComplianceTest):
1103    """
1104    Check that MAINTAINERS file parses correctly.
1105    """
1106    name = "MaintainersFormat"
1107    doc = "Check that MAINTAINERS file parses correctly."
1108    path_hint = "<git-top>"
1109
1110    def run(self):
1111        MAINTAINERS_FILES = ["MAINTAINERS.yml", "MAINTAINERS.yaml"]
1112
1113        for file in MAINTAINERS_FILES:
1114            if not os.path.exists(file):
1115                continue
1116
1117            try:
1118                Maintainers(file)
1119            except MaintainersError as ex:
1120                self.failure(f"Error parsing {file}: {ex}")
1121
1122class ModulesMaintainers(ComplianceTest):
1123    """
1124    Check that all modules have a MAINTAINERS entry.
1125    """
1126    name = "ModulesMaintainers"
1127    doc = "Check that all modules have a MAINTAINERS entry."
1128    path_hint = "<git-top>"
1129
1130    def run(self):
1131        MAINTAINERS_FILES = ["MAINTAINERS.yml", "MAINTAINERS.yaml"]
1132
1133        manifest = Manifest.from_file()
1134
1135        maintainers_file = None
1136        for file in MAINTAINERS_FILES:
1137            if os.path.exists(file):
1138                maintainers_file = file
1139                break
1140        if not maintainers_file:
1141            return
1142
1143        maintainers = Maintainers(maintainers_file)
1144
1145        for project in manifest.get_projects([]):
1146            if not manifest.is_active(project):
1147                continue
1148
1149            if isinstance(project, ManifestProject):
1150                continue
1151
1152            area = f"West project: {project.name}"
1153            if area not in maintainers.areas:
1154                self.failure(f"Missing {maintainers_file} entry for: \"{area}\"")
1155
1156
1157class YAMLLint(ComplianceTest):
1158    """
1159    YAMLLint
1160    """
1161    name = "YAMLLint"
1162    doc = "Check YAML files with YAMLLint."
1163    path_hint = "<git-top>"
1164
1165    def run(self):
1166        config_file = os.path.join(ZEPHYR_BASE, ".yamllint")
1167
1168        for file in get_files(filter="d"):
1169            if Path(file).suffix not in ['.yaml', '.yml']:
1170                continue
1171
1172            yaml_config = config.YamlLintConfig(file=config_file)
1173
1174            if file.startswith(".github/"):
1175                # Tweak few rules for workflow files.
1176                yaml_config.rules["line-length"] = False
1177                yaml_config.rules["truthy"]["allowed-values"].extend(['on', 'off'])
1178            elif file == ".codecov.yml":
1179                yaml_config.rules["truthy"]["allowed-values"].extend(['yes', 'no'])
1180
1181            with open(file, 'r') as fp:
1182                for p in linter.run(fp, yaml_config):
1183                    self.fmtd_failure('warning', f'YAMLLint ({p.rule})', file,
1184                                      p.line, col=p.column, desc=p.desc)
1185
1186
1187class KeepSorted(ComplianceTest):
1188    """
1189    Check for blocks of code or config that should be kept sorted.
1190    """
1191    name = "KeepSorted"
1192    doc = "Check for blocks of code or config that should be kept sorted."
1193    path_hint = "<git-top>"
1194
1195    MARKER = "zephyr-keep-sorted"
1196
1197    def block_is_sorted(self, block_data):
1198        lines = []
1199
1200        for line in textwrap.dedent(block_data).splitlines():
1201            if len(lines) > 0 and line.startswith((" ", "\t")):
1202                # Fold back indented lines
1203                lines[-1] += line.strip()
1204            else:
1205                lines.append(line.strip())
1206
1207        if lines != sorted(lines):
1208            return False
1209
1210        return True
1211
1212    def check_file(self, file, fp):
1213        mime_type = magic.from_file(file, mime=True)
1214
1215        if not mime_type.startswith("text/"):
1216            return
1217
1218        block_data = ""
1219        in_block = False
1220
1221        start_marker = f"{self.MARKER}-start"
1222        stop_marker = f"{self.MARKER}-stop"
1223
1224        for line_num, line in enumerate(fp.readlines(), start=1):
1225            if start_marker in line:
1226                if in_block:
1227                    desc = f"nested {start_marker}"
1228                    self.fmtd_failure("error", "KeepSorted", file, line_num,
1229                                     desc=desc)
1230                in_block = True
1231                block_data = ""
1232            elif stop_marker in line:
1233                if not in_block:
1234                    desc = f"{stop_marker} without {start_marker}"
1235                    self.fmtd_failure("error", "KeepSorted", file, line_num,
1236                                     desc=desc)
1237                in_block = False
1238
1239                if not self.block_is_sorted(block_data):
1240                    desc = f"sorted block is not sorted"
1241                    self.fmtd_failure("error", "KeepSorted", file, line_num,
1242                                      desc=desc)
1243            elif not line.strip() or line.startswith("#"):
1244                # Ignore comments and blank lines
1245                continue
1246            elif in_block:
1247                block_data += line
1248
1249        if in_block:
1250            self.failure(f"unterminated {start_marker} in {file}")
1251
1252    def run(self):
1253        for file in get_files(filter="d"):
1254            with open(file, "r") as fp:
1255                self.check_file(file, fp)
1256
1257def init_logs(cli_arg):
1258    # Initializes logging
1259
1260    global logger
1261
1262    level = os.environ.get('LOG_LEVEL', "WARN")
1263
1264    console = logging.StreamHandler()
1265    console.setFormatter(logging.Formatter('%(levelname)-8s: %(message)s'))
1266
1267    logger = logging.getLogger('')
1268    logger.addHandler(console)
1269    logger.setLevel(cli_arg or level)
1270
1271    logger.info("Log init completed, level=%s",
1272                 logging.getLevelName(logger.getEffectiveLevel()))
1273
1274
1275def inheritors(klass):
1276    subclasses = set()
1277    work = [klass]
1278    while work:
1279        parent = work.pop()
1280        for child in parent.__subclasses__():
1281            if child not in subclasses:
1282                subclasses.add(child)
1283                work.append(child)
1284    return subclasses
1285
1286
1287def annotate(res):
1288    """
1289    https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#about-workflow-commands
1290    """
1291    notice = f'::{res.severity} file={res.file}' + \
1292             (f',line={res.line}' if res.line else '') + \
1293             (f',col={res.col}' if res.col else '') + \
1294             f',title={res.title}::{res.message}'
1295    print(notice)
1296
1297
1298def resolve_path_hint(hint):
1299    if hint == "<zephyr-base>":
1300        return ZEPHYR_BASE
1301    elif hint == "<git-top>":
1302        return GIT_TOP
1303    else:
1304        return hint
1305
1306
1307def parse_args(argv):
1308
1309    default_range = 'HEAD~1..HEAD'
1310    parser = argparse.ArgumentParser(
1311        description="Check for coding style and documentation warnings.", allow_abbrev=False)
1312    parser.add_argument('-c', '--commits', default=default_range,
1313                        help=f'''Commit range in the form: a..[b], default is
1314                        {default_range}''')
1315    parser.add_argument('-o', '--output', default="compliance.xml",
1316                        help='''Name of outfile in JUnit format,
1317                        default is ./compliance.xml''')
1318    parser.add_argument('-n', '--no-case-output', action="store_true",
1319                        help="Do not store the individual test case output.")
1320    parser.add_argument('-l', '--list', action="store_true",
1321                        help="List all checks and exit")
1322    parser.add_argument("-v", "--loglevel", choices=['DEBUG', 'INFO', 'WARNING',
1323                                                     'ERROR', 'CRITICAL'],
1324                        help="python logging level")
1325    parser.add_argument('-m', '--module', action="append", default=[],
1326                        help="Checks to run. All checks by default. (case " \
1327                        "insensitive)")
1328    parser.add_argument('-e', '--exclude-module', action="append", default=[],
1329                        help="Do not run the specified checks (case " \
1330                        "insensitive)")
1331    parser.add_argument('-j', '--previous-run', default=None,
1332                        help='''Pre-load JUnit results in XML format
1333                        from a previous run and combine with new results.''')
1334    parser.add_argument('--annotate', action="store_true",
1335                        help="Print GitHub Actions-compatible annotations.")
1336
1337    return parser.parse_args(argv)
1338
1339def _main(args):
1340    # The "real" main(), which is wrapped to catch exceptions and report them
1341    # to GitHub. Returns the number of test failures.
1342
1343    global ZEPHYR_BASE
1344    ZEPHYR_BASE = os.environ.get('ZEPHYR_BASE')
1345    if not ZEPHYR_BASE:
1346        # Let the user run this script as ./scripts/ci/check_compliance.py without
1347        #  making them set ZEPHYR_BASE.
1348        ZEPHYR_BASE = str(Path(__file__).resolve().parents[2])
1349
1350        # Propagate this decision to child processes.
1351        os.environ['ZEPHYR_BASE'] = ZEPHYR_BASE
1352
1353    # The absolute path of the top-level git directory. Initialize it here so
1354    # that issues running Git can be reported to GitHub.
1355    global GIT_TOP
1356    GIT_TOP = git("rev-parse", "--show-toplevel")
1357
1358    # The commit range passed in --commit, e.g. "HEAD~3"
1359    global COMMIT_RANGE
1360    COMMIT_RANGE = args.commits
1361
1362    init_logs(args.loglevel)
1363
1364    logger.info(f'Running tests on commit range {COMMIT_RANGE}')
1365
1366    if args.list:
1367        for testcase in inheritors(ComplianceTest):
1368            print(testcase.name)
1369        return 0
1370
1371    # Load saved test results from an earlier run, if requested
1372    if args.previous_run:
1373        if not os.path.exists(args.previous_run):
1374            # This probably means that an earlier pass had an internal error
1375            # (the script is currently run multiple times by the ci-pipelines
1376            # repo). Since that earlier pass might've posted an error to
1377            # GitHub, avoid generating a GitHub comment here, by avoiding
1378            # sys.exit() (which gets caught in main()).
1379            print(f"error: '{args.previous_run}' not found",
1380                  file=sys.stderr)
1381            return 1
1382
1383        logging.info(f"Loading previous results from {args.previous_run}")
1384        for loaded_suite in JUnitXml.fromfile(args.previous_run):
1385            suite = loaded_suite
1386            break
1387    else:
1388        suite = TestSuite("Compliance")
1389
1390    included = list(map(lambda x: x.lower(), args.module))
1391    excluded = list(map(lambda x: x.lower(), args.exclude_module))
1392
1393    for testcase in inheritors(ComplianceTest):
1394        # "Modules" and "testcases" are the same thing. Better flags would have
1395        # been --tests and --exclude-tests or the like, but it's awkward to
1396        # change now.
1397
1398        if included and testcase.name.lower() not in included:
1399            continue
1400
1401        if testcase.name.lower() in excluded:
1402            print("Skipping " + testcase.name)
1403            continue
1404
1405        test = testcase()
1406        try:
1407            print(f"Running {test.name:16} tests in "
1408                  f"{resolve_path_hint(test.path_hint)} ...")
1409            test.run()
1410        except EndTest:
1411            pass
1412
1413        # Annotate if required
1414        if args.annotate:
1415            for res in test.fmtd_failures:
1416                annotate(res)
1417
1418        suite.add_testcase(test.case)
1419
1420    if args.output:
1421        xml = JUnitXml()
1422        xml.add_testsuite(suite)
1423        xml.update_statistics()
1424        xml.write(args.output, pretty=True)
1425
1426    failed_cases = []
1427    name2doc = {testcase.name: testcase.doc
1428                for testcase in inheritors(ComplianceTest)}
1429
1430    for case in suite:
1431        if case.result:
1432            if case.is_skipped:
1433                logging.warning(f"Skipped {case.name}")
1434            else:
1435                failed_cases.append(case)
1436        else:
1437            # Some checks like codeowners can produce no .result
1438            logging.info(f"No JUnit result for {case.name}")
1439
1440    n_fails = len(failed_cases)
1441
1442    if n_fails:
1443        print(f"{n_fails} checks failed")
1444        for case in failed_cases:
1445            for res in case.result:
1446                errmsg = res.text.strip()
1447                logging.error(f"Test {case.name} failed: \n{errmsg}")
1448            if args.no_case_output:
1449                continue
1450            with open(f"{case.name}.txt", "w") as f:
1451                docs = name2doc.get(case.name)
1452                f.write(f"{docs}\n")
1453                for res in case.result:
1454                    errmsg = res.text.strip()
1455                    f.write(f'\n {errmsg}')
1456
1457    if args.output:
1458        print(f"\nComplete results in {args.output}")
1459    return n_fails
1460
1461
1462def main(argv=None):
1463    args = parse_args(argv)
1464
1465    try:
1466        # pylint: disable=unused-import
1467        from lxml import etree
1468    except ImportError:
1469        print("\nERROR: Python module lxml not installed, unable to proceed")
1470        print("See https://github.com/weiwei/junitparser/issues/99")
1471        return 1
1472
1473    try:
1474        n_fails = _main(args)
1475    except BaseException:
1476        # Catch BaseException instead of Exception to include stuff like
1477        # SystemExit (raised by sys.exit())
1478        print(f"Python exception in `{__file__}`:\n\n"
1479              f"```\n{traceback.format_exc()}\n```")
1480
1481        raise
1482
1483    sys.exit(n_fails)
1484
1485
1486def cmd2str(cmd):
1487    # Formats the command-line arguments in the iterable 'cmd' into a string,
1488    # for error messages and the like
1489
1490    return " ".join(shlex.quote(word) for word in cmd)
1491
1492
1493def err(msg):
1494    cmd = sys.argv[0]  # Empty if missing
1495    if cmd:
1496        cmd += ": "
1497    sys.exit(f"{cmd} error: {msg}")
1498
1499
1500if __name__ == "__main__":
1501    main(sys.argv[1:])
1502