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