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