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