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