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