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